From 23238aee0b5980af9611c7b5006ef96c9daae0de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:05 -0700 Subject: [PATCH 01/88] fix(mobile): stop native-chat send button flicker (#12764) * fix(mobile): stop native-chat send button flicker * fix(mobile): keep composer lock rendering pure --- .../src/session/MobileNativeChatView.test.ts | 49 ++++++++++++++++++- mobile/src/session/MobileNativeChatView.tsx | 16 +++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..21a5a7c4aa1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({ vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a35f3caed3c..48a1fcd31d3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +const INPUT_LOCK_SETTLE_MS = 600 + /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' @@ -250,20 +252,18 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + // A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled. const rawLockReason = inputLockReason ?? null + const rawLockHeld = rawLockReason !== null const [lockHeld, setLockHeld] = useState(false) useEffect(() => { - if (rawLockReason === null) { - setLockHeld(false) + if (rawLockHeld === lockHeld) { return } - const timer = setTimeout(() => setLockHeld(true), 600) + const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS) return () => clearTimeout(timer) - }, [rawLockReason]) - const lockReason = lockHeld ? rawLockReason : null + }, [lockHeld, rawLockHeld]) + const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null return ( From d4dfc35ac4bdacc44fd98703a2cd8e880de0a5c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:23:29 -0700 Subject: [PATCH 02/88] fix(mobile): preserve multi-image chat attachments (#12639) * fix(mobile): preserve multi-image chat attachments * fix(mobile): use preferred array syntax * fix(mobile): harden multi-image attachment flow * fix(mobile): retain first-send image previews --- .../session/MobileNativeChatOverlay.test.ts | 1 + .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 14 +- .../mobile-image-source-picker.test.ts | 68 +++++++- .../src/session/mobile-image-source-picker.ts | 88 +++++++---- ...mobile-native-chat-draft-reconcile.test.ts | 72 +++++++++ .../mobile-native-chat-draft-reconcile.ts | 146 ++++++++++++++++- ...obile-native-chat-image-attachment.test.ts | 104 ++++++++++-- .../mobile-native-chat-image-attachment.ts | 65 ++++++-- .../mobile-native-chat-image-send.test.ts | 12 +- .../mobile-native-chat-pending-echo.ts | 91 +++++++++++ .../mobile-native-chat-render-data.test.ts | 30 ++++ .../session/mobile-native-chat-render-data.ts | 30 +++- .../use-mobile-native-chat-controller.test.ts | 1 + .../use-mobile-native-chat-controller.ts | 3 + .../use-mobile-native-chat-drafts.test.ts | 72 ++++++++- .../session/use-mobile-native-chat-drafts.ts | 148 ++++++++++-------- ...bile-native-chat-image-attachments.test.ts | 52 +++--- ...se-mobile-native-chat-image-attachments.ts | 54 ++++--- ...tive-chat-image-transcript-markers.test.ts | 46 ++++++ .../native-chat-image-transcript-markers.ts | 65 ++++---- 21 files changed, 944 insertions(+), 219 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-draft-reconcile.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-echo.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2680064c94b..c8e62c146cb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4361c185d3c..699f8a1614b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 48a1fcd31d3..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -61,6 +61,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -125,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -177,8 +181,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a1ed942316f..a9cd1201639 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 49d75409c05..5ed98925e9c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -50,6 +50,7 @@ export type MobileNativeChatController = { chatComposerText: string setChatComposerText: Dispatch> chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string @@ -160,6 +161,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -326,6 +328,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' -export type MobileNativeChatPendingMessage = { - id: string - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { // A single Ctrl+U kills only the last line, so the draft's earlier lines // would survive the clear and ride along with the image as prompt body. - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 58d6d0d16c5..e4856605a74 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -35,6 +35,52 @@ describe('normalizeImageTranscriptMessages', () => { ]) }) + it('folds every source and strips every prompt marker for a multi-image send', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('c', '[Image: source: /tmp/c.png]'), + userText('prompt', '[Image #1] [Image #2] [Image #3] compare these') + ]) + + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ id: 'prompt' }) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'image-ref', path: '/tmp/c.png' }, + { type: 'text', text: 'compare these' } + ]) + }) + + it('keeps all image refs when a multi-image send has no caption', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('prompt', '[Image #1] [Image #2]') + ]) + + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' } + ]) + }) + + it('preserves adjacent standalone image turns without a prompt marker', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]') + ]) + + expect(out).toHaveLength(2) + expect(out.map((message) => message.id)).toEqual(['a', 'b']) + expect(out.map((message) => message.blocks)).toEqual([ + [{ type: 'image-ref', path: '/tmp/a.png' }], + [{ type: 'image-ref', path: '/tmp/b.png' }] + ]) + }) + it('leaves ordinary user text untouched', () => { const out = normalizeImageTranscriptMessages([userText('a', 'how about this')]) expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'how about this' }]) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 3f8f90f8cdb..9643cf518e0 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -1,7 +1,7 @@ import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types' const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/ -const IMAGE_PROMPT_MARKER = /^\[Image #\d+\]\s*/ +const IMAGE_PROMPT_MARKERS = /^(?:\[Image #\d+\]\s*)+/ function soleText(message: NativeChatMessage): string | null { return message.blocks.length === 1 && isTextBlock(message.blocks[0]) @@ -14,10 +14,12 @@ export function imageSourcePathFromText(text: string): string | null { } export function stripImagePromptMarker(text: string): string { - return text.replace(IMAGE_PROMPT_MARKER, '') + return text.replace(IMAGE_PROMPT_MARKERS, '') } -function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { +function stripImagePromptMarkersFromFirstText( + blocks: readonly NativeChatBlock[] +): NativeChatBlock[] { let stripped = false const next: NativeChatBlock[] = [] for (const block of blocks) { @@ -36,13 +38,11 @@ function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): Native function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean { const firstText = message.blocks.find(isTextBlock) - return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false + return firstText ? IMAGE_PROMPT_MARKERS.test(firstText.text) : false } -/** Claude records an attached image as two user transcript turns: - * `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into - * one native turn so the UI keeps the same chip+text shape as the optimistic - * send and does not show raw TUI marker text after a view remount. */ +/** Claude records image paths as source turns followed by one marker-prefixed + * prompt. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( messages: readonly NativeChatMessage[] ): NativeChatMessage[] { @@ -54,27 +54,34 @@ export function normalizeImageTranscriptMessages( continue } const imagePath = imageSourcePathFromText(soleText(message) ?? '') - const next = messages[index + 1] - if ( - imagePath && - next?.role === 'user' && - next.source === message.source && - imagePromptMarkerStartsMessage(next) - ) { - normalized.push({ - ...next, - blocks: [ - { type: 'image-ref', path: imagePath }, - ...stripFirstImagePromptMarker(next.blocks) - ] - }) - index += 1 - continue - } - // A lone `[Image: source: /path]` turn (no following `[Image #1]` prompt — - // e.g. an image sent with no caption) still renders as an image chip rather - // than the raw marker text. if (imagePath) { + const imagePaths = [imagePath] + let nextIndex = index + 1 + while (nextIndex < messages.length) { + const candidate = messages[nextIndex]! + const candidatePath = imageSourcePathFromText(soleText(candidate) ?? '') + if (candidate.role !== 'user' || candidate.source !== message.source || !candidatePath) { + break + } + imagePaths.push(candidatePath) + nextIndex += 1 + } + const prompt = messages[nextIndex] + if ( + prompt?.role === 'user' && + prompt.source === message.source && + imagePromptMarkerStartsMessage(prompt) + ) { + normalized.push({ + ...prompt, + blocks: [ + ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), + ...stripImagePromptMarkersFromFirstText(prompt.blocks) + ] + }) + index = nextIndex + continue + } normalized.push({ ...message, blocks: [{ type: 'image-ref', path: imagePath }] @@ -83,7 +90,7 @@ export function normalizeImageTranscriptMessages( } normalized.push({ ...message, - blocks: stripFirstImagePromptMarker(message.blocks) + blocks: stripImagePromptMarkersFromFirstText(message.blocks) }) } return normalized From 74ac7049ecfa244e4f0384ccfb555036163033c1 Mon Sep 17 00:00:00 2001 From: Kyou <1129057944@qq.com> Date: Thu, 6 Aug 2026 04:31:26 +0800 Subject: [PATCH 03/88] fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset (#11782) * fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset Fixes #9358 and #9941. cmd.exe expands %VAR:~n,m% at parse time. When GROK_HOME is unset (default outside Orca terminals), the generated length/trailing-backslash guards became a syntax error and every Grok hook event failed with exit 255. - Skip substring work when GROK_HOME is undefined (if defined + goto) - Replace if "%x:~-1%"=="\" (itself a quote-parser bug) with findstr - Extract Windows script builder; add template + spawn tests * fix(windows): harden grok-hook GROK_HOME guards and tests Address review on #11782: - Inject grokHome via buildWindowsAgentHookPostCommand extra form lines (no fragile string replace of the shared payload line) - Spawn tests delete GROK_HOME and keep PORT/TOKEN/PANE_KEY set so the GROK_HOME path actually runs before curl * fix(windows): cover Grok hook home boundaries --------- Co-authored-by: OrcaWin --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/installer-utils.ts | 10 +- src/main/grok/hook-service.test.ts | 187 +++++++++++++++++++++- src/main/grok/hook-service.ts | 32 +--- src/main/grok/windows-grok-hook-script.ts | 45 ++++++ 5 files changed, 243 insertions(+), 32 deletions(-) create mode 100644 src/main/grok/windows-grok-hook-script.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..3e0e59381a4 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 02810726347..069b3b954be 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -16,12 +18,86 @@ vi.mock('os', async () => { }) import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' +import { buildWindowsGrokHookScript } from './windows-grok-hook-script' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ +type WindowsGrokHookRun = { + status: number | null + stderr: string + stdout: string + request?: { path: string; body: string } +} + +function createWindowsGrokHookEnvironment(grokHome?: string): NodeJS.ProcessEnv { + const env = { ...process.env } as NodeJS.ProcessEnv + delete env.ORCA_AGENT_HOOK_ENDPOINT + if (grokHome === undefined) { + delete env.GROK_HOME + } else { + env.GROK_HOME = grokHome + } + env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + env.ORCA_PANE_KEY = 'pane-test' + return env +} + +async function runWindowsGrokHook( + scriptPath: string, + env: NodeJS.ProcessEnv, + input: string +): Promise { + let request: WindowsGrokHookRun['request'] + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { + body += chunk + }) + incoming.on('end', () => { + request = { path: incoming.url ?? '', body } + response.writeHead(204).end() + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Could not resolve Windows Grok hook test listener port') + } + env.ORCA_AGENT_HOOK_PORT = String(address.port) + try { + const result = await new Promise>((resolve, reject) => { + const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', scriptPath], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stderr = '' + let stdout = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.once('error', reject) + child.once('close', (status) => resolve({ status, stderr, stdout })) + child.stdin.end(input) + }) + return { ...result, request } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + describe('GrokHookService', () => { let homeDir: string @@ -35,6 +111,108 @@ describe('GrokHookService', () => { rmSync(homeDir, { recursive: true, force: true }) }) + // Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke + // every SessionStart/UserPromptSubmit on Windows outside Orca terminals. + it('guards Windows GROK_HOME substring checks when empty (#9358)', () => { + const script = buildWindowsGrokHookScript() + expect(script).toContain('set "ORCA_GROK_HOME="') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain(':orca_grok_home_ready') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') + expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready') + // Why: parenthesized `if defined (...)` still parse-expands the body early. + expect(script).not.toMatch(/if defined GROK_HOME \(/) + }) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-unset.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + // Why: delete GROK_HOME rather than set '' so cmd sees "not defined". + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(), + '{"hook_event_name":"SessionStart"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"SessionStart"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with trailing-backslash GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-slash.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailing = `${join(homeDir, 'grok-home-with-slash')}\\` + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailing), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe(`${trailing}.`) + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with an oversized GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-oversized.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(`C:\\${'a'.repeat(9000)}`), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'omits a max-length trailing-backslash GROK_HOME after safe normalization (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-max-trailing.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailingAtLimit = `C:\\${'a'.repeat(4092)}\\` + expect(trailingAtLimit).toHaveLength(4096) + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailingAtLimit), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + it('installs a dedicated global Grok hook config and managed script', () => { const status = new GrokHookService().install() @@ -89,11 +267,12 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain( - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' - ) + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 564565d0d24..51a6150eab6 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -5,7 +5,6 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -20,19 +19,17 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' import { - buildPosixHookPayloadCapture, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' + buildWindowsGrokHookScript, + GROK_HOME_ENVELOPE_MAX_LENGTH +} from './windows-grok-hook-script' // Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare // `*` is not a valid "match all" pattern and can fail to load/match, so tool // lifecycle hooks never fire. `.*` matches every tool name (same as Command // Code's managed hooks). const GROK_TOOL_EVENT_MATCHER = '.*' -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 -const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, @@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean { }) } -const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( - WINDOWS_HOOK_PAYLOAD_FORM_LINE, - ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` -) - function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -116,21 +108,7 @@ function getManagedCommand(scriptPath: string): string { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - ...buildWindowsHookEnvironmentGuardLines(), - 'set "ORCA_GROK_HOME=%GROK_HOME%"', - `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, - // Why: a trailing backslash escapes curl's closing argv quote on Windows, - // merging the payload option into grokHome and dropping the hook body. - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', - WINDOWS_GROK_HOOK_POST_COMMAND, - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') + return buildWindowsGrokHookScript() } return [ diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts new file mode 100644 index 00000000000..fb396dfafbc --- /dev/null +++ b/src/main/grok/windows-grok-hook-script.ts @@ -0,0 +1,45 @@ +import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +/** Matches the envelope length cap used by the POSIX grok-hook branch. */ +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 + +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [ + // Why: attach grokHome before payload@- without string-replacing the shared template. + ' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^' +]) + +/** + * Windows `grok-hook.cmd` body. + * + * Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME` + * is unset (the default outside an Orca-managed terminal), length/trailing + * guards become a syntax error and every Grok hook event fails with exit 255. + * + * - Guard substring ops behind `if defined` + goto (not a parenthesized block). + * - Reject oversized values before copying them onto a cmd input line. + */ +export function buildWindowsGrokHookScript(): string { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + 'set "ORCA_GROK_HOME="', + 'if not defined GROK_HOME goto :orca_grok_home_ready', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + // Why: the trailing-backslash safety sentinel counts toward the relay envelope. + `if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + ':orca_grok_home_ready', + WINDOWS_GROK_HOOK_POST_COMMAND, + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') +} From 885afb55a9daaefe2aa566529f2522c237ff3c09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:32:58 -0700 Subject: [PATCH 04/88] fix(mobile): open host editor from root navigation (#12766) * fix(mobile): open host editor from root navigation * test(mobile): update task navigation router contract --- .../accounts/mobile-accounts-route.test.ts | 2 +- .../navigation/host-stack-navigation.test.ts | 50 +++++++++++++--- .../src/navigation/host-stack-navigation.ts | 14 +++++ .../notification-route-coordination.test.ts | 7 ++- .../src/session/mobile-session-route.test.ts | 2 +- .../src/tasks/mobile-task-navigation.test.ts | 60 +++++++++---------- .../transport/host-edit-navigation.test.ts | 12 ++-- 7 files changed, 102 insertions(+), 45 deletions(-) diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 03893215f2e..f938a98772c 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -45,7 +45,7 @@ describe('mobile accounts route', () => { navigateToHostStackRoute( harness.navigation, - { push }, + { push, replace: vi.fn() }, 'host/one', mobileAccountsRouteTarget('host/one') ) diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index dc3a94cfe7a..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -69,7 +70,7 @@ describe('host stack navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -89,7 +90,12 @@ describe('host stack navigation', () => { rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) ) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(rootLayoutScopedState(committedHostState('host/one'))) expect(harness.navigation.dispatch).toHaveBeenCalledWith({ @@ -103,7 +109,12 @@ describe('host stack navigation', () => { it('survives the state emitted before the root navigator has hydrated', () => { const harness = navigationHarness(undefined) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) expect(() => harness.setState(undefined)).not.toThrow() expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -112,10 +123,30 @@ describe('host stack navigation', () => { expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) }) + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + it('abandons the transition when navigation leaves the host route it was waiting on', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState({ index: 0, routes: [{ name: 'h' }] }) harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState(committedHostState('host/one')) @@ -127,7 +158,12 @@ describe('host stack navigation', () => { it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -138,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -156,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 6f73dd3e57a..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -35,8 +35,14 @@ export type HostStackRootNavigation = { export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -144,6 +154,10 @@ export function navigateToHostStackRoute( } const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index e41df45a8a2..eab13fdc046 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -47,7 +47,12 @@ describe('notification route coordination', () => { const harness = navigationHarness(undefined) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!) + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 56b9fac7aa4..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index cd0de461288..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => { it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') harness.setState(committedHostState('host-2')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => { it('disposes itself when navigation leaves the host flow without cancel()', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') // Enter the host flow for a different host, then leave it entirely. harness.setState(committedHostState('host-2')) @@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => { it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1' + ) harness.setState(committedHostState('host-2')) controller.cancel() From 7f4570c9a669d6b2c683ecd07e822fb95d32ff56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:08 -0700 Subject: [PATCH 05/88] fix(mobile): activate Source Control diff tabs on phones (#12770) * fix(mobile): activate source-control diff tabs on phones * fix(mobile): reveal legacy source control file tabs --- ...mobile-source-control-session-diff.test.ts | 233 ++++++++++++++++++ ...veal-mobile-source-control-session-diff.ts | 143 +++++++++++ .../use-mobile-source-control-openers.ts | 19 +- 3 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.ts diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock From be2f9eddd39e668e19f9b0dd5fc3352125de8229 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:45:39 -0700 Subject: [PATCH 06/88] fix(mobile): abandon pairing journals that can no longer reconcile (#12773) --- .../mobile-relay-pairing-recovery.test.ts | 49 +++++++++++++++++++ .../mobile-relay-pairing-recovery.ts | 25 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } From 73cd4c3f46576b191a7625373bdc628120650dcf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:52:14 -0700 Subject: [PATCH 07/88] fix(mobile): bound live terminal input latency (#12763) --- .../app/h/[hostId]/session/[worktreeId].tsx | 1 + .../session/MobileTerminalLiveInputStatus.tsx | 8 +- .../terminal-live-input-affordance.test.ts | 3 + .../terminal-live-pending-flush-state.test.ts | 77 ++++++++++-- .../terminal-live-pending-flush-state.ts | 111 +++++++++++++++--- .../use-terminal-live-pending-input-flush.ts | 24 +++- 6 files changed, 184 insertions(+), 40 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5e4175acc0..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4986,6 +4986,7 @@ export default function SessionScreen() { {title} - + {detail} diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) From debf4affe7ed2dcbb4ad9fdad94a87bd92745831 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:46:00 -0700 Subject: [PATCH 08/88] Display total lines of code change in branch header (#12771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add branch line total chip to source control header Display the total lines added and removed across a branch from its fork point, measured via `git diff `. Only computed when the chip is visible (request gate on merge base OID), with 500ms soft deadline to protect status latency and 15s hard timeout. Deduplicated across concurrent pollers and cached alongside line stats. Omitted on failure — always shows exact or nothing, never a partial estimate. Updates throughout the stack: native git status, relay, renderer store/API, and UI components. * Pin branch line total to app locale Format line counts using the app's configured locale instead of the system locale, ensuring consistent cross-platform display and test reliability. * test: wait for coalescer joins instead of fixed sleep Hold the diff until the second status pass actually takes the branch-total coalescer lease instead of using a fixed 400ms sleep. Fixes timing-dependent flakiness on slow machines. --- src/main/git/git-status-read-lease-owner.ts | 104 +--- ...us-branch-line-total-exec-contract.test.ts | 330 ++++++++++++ .../status-branch-line-total-real-git.test.ts | 203 ++++++++ ...tus-branch-line-total-relay-parity.test.ts | 172 +++++++ src/main/git/status.ts | 68 ++- src/main/ipc/filesystem.ts | 4 + .../providers/git-provider-status-options.ts | 2 + src/main/providers/ssh-git-provider.ts | 7 +- src/main/runtime/rpc/methods/git-params.ts | 4 +- src/main/runtime/rpc/methods/git.ts | 4 + src/preload/api-types.ts | 2 + src/preload/index.ts | 1 + src/relay/git-handler-status-ops.ts | 33 +- src/relay/git-handler.ts | 2 + .../git-status-branch-line-total.test.ts | 475 ++++++++++++++++++ src/relay/git-status-branch-line-total.ts | 44 ++ .../SourceControl.branch-line-total.test.tsx | 340 +++++++++++++ ...SourceControl.open-file-highlight.test.tsx | 1 + .../SourceControl.preview-open.test.tsx | 1 + .../right-sidebar/SourceControl.tsx | 27 + .../SourceControl.virtual-file-list.test.tsx | 1 + .../branch-line-total-request-gate.test.ts | 52 ++ .../branch-line-total-request-gate.ts | 23 + ...t-status-refresh-branch-line-total.test.ts | 224 +++++++++ .../right-sidebar/git-status-refresh.ts | 23 +- ...source-control-branch-context-row.test.tsx | 142 ++++++ .../source-control-branch-context-row.tsx | 31 +- .../source-control-branch-context-stats.ts | 12 +- .../source-control-branch-line-total-chip.tsx | 74 +++ ...e-control-header-toolbar-identity.test.tsx | 16 + .../source-control-header-toolbar.tsx | 6 +- src/renderer/src/i18n/locales/en.json | 11 + ...ntime-git-client-branch-line-total.test.ts | 133 +++++ .../src/runtime/runtime-git-client.ts | 10 +- .../slices/editor-branch-line-total.test.ts | 225 +++++++++ src/renderer/src/store/slices/editor.ts | 28 ++ .../src/store/slices/worktrees.test.ts | 21 + src/renderer/src/store/slices/worktrees.ts | 5 + src/renderer/src/web/web-preload-api.test.ts | 93 ++++ src/renderer/src/web/web-preload-api.ts | 4 +- ...it-branch-line-total-soft-deadline.test.ts | 62 +++ src/shared/git-branch-line-total.test.ts | 434 ++++++++++++++++ src/shared/git-branch-line-total.ts | 214 ++++++++ ...git-status-branch-line-total-cache.test.ts | 236 +++++++++ src/shared/git-status-line-stats-cache.ts | 220 +++++--- .../git-status-line-stats-write-token.ts | 69 +++ src/shared/git-status-read-lease-owner.ts | 101 ++++ src/shared/git-status-types.ts | 12 + 48 files changed, 4112 insertions(+), 194 deletions(-) create mode 100644 src/main/git/status-branch-line-total-exec-contract.test.ts create mode 100644 src/main/git/status-branch-line-total-real-git.test.ts create mode 100644 src/main/git/status-branch-line-total-relay-parity.test.ts create mode 100644 src/relay/git-status-branch-line-total.test.ts create mode 100644 src/relay/git-status-branch-line-total.ts create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts create mode 100644 src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx create mode 100644 src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts create mode 100644 src/renderer/src/store/slices/editor-branch-line-total.test.ts create mode 100644 src/shared/git-branch-line-total-soft-deadline.test.ts create mode 100644 src/shared/git-branch-line-total.test.ts create mode 100644 src/shared/git-branch-line-total.ts create mode 100644 src/shared/git-status-branch-line-total-cache.test.ts create mode 100644 src/shared/git-status-line-stats-write-token.ts create mode 100644 src/shared/git-status-read-lease-owner.ts diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts index a0012da7d3a..eeae5eedf95 100644 --- a/src/main/git/git-status-read-lease-owner.ts +++ b/src/main/git/git-status-read-lease-owner.ts @@ -1,101 +1,3 @@ -type StatusReadEntry = { - controller: AbortController - promise: Promise - liveLeases: number - settled: boolean -} - -function getAbortReason(signal: AbortSignal): unknown { - try { - signal.throwIfAborted() - } catch (error) { - return error - } - return new DOMException('This operation was aborted', 'AbortError') -} - -export class GitStatusReadLeaseOwner { - private readonly entries = new Map>() - - lease( - key: string, - signal: AbortSignal | undefined, - load: (sharedSignal: AbortSignal) => Promise - ): Promise { - if (signal?.aborted) { - return Promise.reject(getAbortReason(signal)) - } - - let entry = this.entries.get(key) - if (!entry) { - const controller = new AbortController() - const promise = load(controller.signal) - const createdEntry = { controller, promise, liveLeases: 0, settled: false } - entry = createdEntry - this.entries.set(key, createdEntry) - void promise.then( - () => this.settle(key, createdEntry), - () => this.settle(key, createdEntry) - ) - } - - entry.liveLeases += 1 - return this.createLease(key, entry, signal) - } - - invalidate(): void { - this.entries.clear() - } - - private createLease( - key: string, - entry: StatusReadEntry, - signal: AbortSignal | undefined - ): Promise { - return new Promise((resolve, reject) => { - let active = true - const release = (abortReason?: unknown): boolean => { - if (!active) { - return false - } - active = false - signal?.removeEventListener('abort', onAbort) - entry.liveLeases -= 1 - if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - entry.controller.abort(abortReason) - } - return true - } - const onAbort = (): void => { - const reason = getAbortReason(signal!) - if (release(reason)) { - reject(reason) - } - } - - signal?.addEventListener('abort', onAbort, { once: true }) - void entry.promise.then( - (value) => { - if (release()) { - resolve(value) - } - }, - (error: unknown) => { - if (release()) { - reject(error) - } - } - ) - }) - } - - private settle(key: string, entry: StatusReadEntry): void { - entry.settled = true - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - } -} +// Why: the class moved to src/shared so the relay host can coalesce the same +// reads; this re-export keeps main's import path (and its tests) stable. +export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner' diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts new file mode 100644 index 00000000000..13ece36daec --- /dev/null +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -0,0 +1,330 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildGitBranchLineTotalDiffArgs, + invalidateGitBranchLineTotalInFlight +} from '../../shared/git-branch-line-total' + +import type * as BranchLineTotal from '../../shared/git-branch-line-total' +import type * as GitRunner from './runner' + +// Why: real git, spied argv. Every exec is recorded so the performance contract +// ("no ranged diff unless asked", "one exec for concurrent callers") can be +// asserted on the actual command list rather than on a stubbed return value. +const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ + gitExecCalls: [] as string[][], + execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, + coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) } +})) + +vi.mock('../../shared/git-branch-line-total', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeGitBranchLineTotal: ( + input: Parameters[0] + ) => { + // The lease is taken synchronously inside, so counting after the call + // means a join is observable the instant it has happened. + const total = actual.computeGitBranchLineTotal(input) + coalescerJoins.count += 1 + coalescerJoins.onJoin?.() + return total + } + } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + await execHooks.beforeExec?.(args[0]) + return actual.gitExecFileAsync(...args) + }, + gitExecFileAsyncBuffer: async ( + ...args: Parameters + ) => { + gitExecCalls.push(args[0]) + return actual.gitExecFileAsyncBuffer(...args) + }, + gitStreamStdout: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + return actual.gitStreamStdout(...args) + } + } +}) + +import { + clearEffectiveUpstreamStatusCacheForTests, + clearSubmodulePathsCacheForTests, + getStatus, + invalidateGitReadCaches +} from './status' + +const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + await write(repo, 'f.txt', 'a\nb\n') + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'base']) + await write(repo, 'f.txt', 'a\nb\nc\n') + return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) } +} + +async function write(repo: string, relativePath: string, contents: string): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */ +function rangedDiffCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') +} + +function numstatCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat')) +} + +/** Resolves once `count` status passes have entered the branch-total coalescer. */ +function waitForCoalescerJoins(count: number): Promise { + if (coalescerJoins.count >= count) { + return Promise.resolve() + } + return new Promise((resolve) => { + coalescerJoins.onJoin = () => { + if (coalescerJoins.count >= count) { + coalescerJoins.onJoin = undefined + resolve() + } + } + }) +} + +function resetGitReadCaches(): void { + clearEffectiveUpstreamStatusCacheForTests() + clearSubmodulePathsCacheForTests() + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +} + +beforeEach(() => { + gitExecCalls.length = 0 + execHooks.beforeExec = undefined + coalescerJoins.count = 0 + coalescerJoins.onJoin = undefined + resetGitReadCaches() +}) + +afterEach(async () => { + execHooks.beforeExec = undefined + coalescerJoins.onJoin = undefined + resetGitReadCaches() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total completeness', () => { + it('omits the total when the status listing hit its limit', async () => { + const { repo, mergeBase } = await createFixtureRepo() + await write(repo, 'u1.txt', 'a\n') + await write(repo, 'u2.txt', 'b\n') + await write(repo, 'u3.txt', 'c\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 }) + + expect(result.didHitLimit).toBe(true) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('omits the total when the ranged numstat fails, rather than reporting zero', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.entries.length).toBeGreaterThan(0) + expect(result.branchLineTotal).toBeUndefined() + // The diff really was attempted, so this is a failure path and not a gate. + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('omits the total for a directory that is not a git worktree', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-')) + tempRoots.push(root) + + const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { + const { repo, mergeBase } = await createFixtureRepo() + const controller = new AbortController() + execHooks.beforeExec = (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + controller.abort() + } + } + + await expect( + getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + // The pass had already streamed status and reached the ranged diff, so this + // is a cancellation in flight rather than a pre-flight rejection. + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) + +describe('branch line total merge-base gate', () => { + it('runs no ranged diff and returns no total without a merge base', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])( + 'never spawns a ranged diff for a merge base of %j', + async (branchLineTotalMergeBase) => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + expect(gitExecCalls).not.toContainEqual( + buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase) + ) + } + ) + + it('never leaks a flag-shaped merge base into any git argv', async () => { + const { repo } = await createFixtureRepo() + + await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' }) + + for (const args of gitExecCalls) { + expect(args).not.toContain('--upload-pack=/tmp/evil') + } + }) +}) + +describe('branch line total exec budget', () => { + it('costs exactly one extra exec versus the same pass with the chip hidden', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + await getStatus(repo) + const withoutChip = gitExecCalls.map((args) => args.join(' ')) + + gitExecCalls.length = 0 + resetGitReadCaches() + await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + const withChip = gitExecCalls.map((args) => args.join(' ')) + + expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip) + expect(withChip).toHaveLength(withoutChip.length + 1) + }) + + it('shares one ranged diff between two concurrent identical status passes', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('shares one ranged diff across status passes the status lease cannot merge', async () => { + const { repo, mergeBase } = await createFixtureRepo() + // Why: `limit` is part of the status read key, so these two passes each run + // their own `git status`; only the branch-total coalescer can merge the diff. + // Hold the first diff exactly until the second pass has taken the lease: a + // fixed sleep would either release too early or, on a slow machine, outrun + // GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total. + execHooks.beforeExec = async (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + await waitForCoalescerJoins(2) + } + } + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + // Proves the saving came from the branch-total coalescer: both passes really + // ran their own `git status`, so the status lease merged nothing. + expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('reuses the cached total when the pass reuses cached line stats', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const first = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + expect(rangedDiffCalls()).toHaveLength(1) + expect(numstatCalls()).toHaveLength(2) + + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const second = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(numstatCalls()).toEqual([]) + }) + + it('recomputes rather than reusing a total measured against another merge base', async () => { + const { repo, mergeBase } = await createFixtureRepo() + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'second']) + const laterMergeBase = git(repo, ['rev-parse', 'HEAD']) + + await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true }) + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const result = await getStatus(repo, { + branchLineTotalMergeBase: laterMergeBase, + reuseLineStats: true + }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts new file mode 100644 index 00000000000..80426c76e65 --- /dev/null +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -0,0 +1,203 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { getStatus, invalidateGitReadCaches } from './status' + +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + return repo +} + +async function write(repo: string, relativePath: string, contents: string | Buffer): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The forked-from commit the chip measures against, as branch compare would resolve it. */ +function commitAll(repo: string, message: string): string { + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', message]) + return git(repo, ['rev-parse', 'HEAD']) +} + +/** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ +function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) { + if (!line) { + continue + } + const [rawAdded, rawRemoved] = line.split('\t') + added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10) + removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10) + } + return { added, removed } +} + +beforeEach(() => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +}) + +afterEach(async () => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total against a real repository', () => { + it('measures a partially staged line once, not once per status area', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'line1\n') + const mergeBase = commitAll(repo, 'base') + // Stage "+foo", then edit that same line to "bar" without staging it: the + // per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1. + await write(repo, 'f.txt', 'line1\nfoo\n') + git(repo, ['add', 'f.txt']) + await write(repo, 'f.txt', 'line1\nbar\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ + ['staged', 1, 0], + ['unstaged', 1, 1] + ]) + }) + + it('nets a line added in a branch commit and deleted in the working tree to zero', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\n') + commitAll(repo, 'add b') + await write(repo, 'f.txt', 'a\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('keeps the total across a commit and updates it on the next status call after an edit', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + + const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + commitAll(repo, 'commit the edit') + const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('counts a rename across the commit boundary once, using the post-rename path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'old.txt', 'new.txt']) + commitAll(repo, 'rename') + await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + }) + + it('counts an untracked-only branch from the untracked file contents', async () => { + const repo = await createFixtureRepo() + await write(repo, 'kept.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/new.ts', 'one\ntwo\nthree\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) + expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { + const repo = await createFixtureRepo() + await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66])) + const mergeBase = commitAll(repo, 'base') + await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70])) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('contributes nothing for a pure rename', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'f.txt', 'g.txt']) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('includes untracked additions alongside tracked range changes', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + commitAll(repo, 'append c') + await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + }) + + // Documented deviation from a pure `git diff`: the untracked half is added on + // top of the tracked range, so a deleted-then-recreated path is counted twice. + // Locked in deliberately — changing it must be a deliberate spec change. + it('counts both the deletion and the recreated untracked file for the same path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'gone.txt', 'a\nb\nc\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['rm', '-q', 'gone.txt']) + commitAll(repo, 'delete gone.txt') + await write(repo, 'gone.txt', 'n1\nn2\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([ + ['untracked', 'gone.txt'] + ]) + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) + expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + }) +}) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts new file mode 100644 index 00000000000..e9f5240728c --- /dev/null +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -0,0 +1,172 @@ +/** + * Main and relay must publish the same `branchLineTotal` for the same fixture + * repo. Both call sites + * share `src/shared/git-branch-line-total.ts`; this is the test that catches one + * of them wiring it up differently — different flags, a different untracked + * source, a different completeness gate. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getStatus } from './status' +import type { GitExec } from '../../relay/git-handler-ops' +import { getStatusOp } from '../../relay/git-handler-status-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' + +const execFileAsync = promisify(execFile) + +// Fork point → working tree for the fixture below: +// tracked.txt +2 (branch commit only) +// partial.txt +1 (staged +2 then one of those lines removed unstaged) +// flip.txt 0 (added in the branch commit, removed again in the worktree) +// moved.txt 0 (pure rename) +// fresh.txt +3 (untracked) +const EXPECTED_TOTAL = { added: 6, removed: 0 } +// Summing the per-area status rows instead would give this — the wrong answer +// the shared module exists to avoid. +const AREA_ROW_SUM = { added: 5, removed: 2 } + +const relayGit: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { + const { stdout } = await relayGit(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** Returns the merge-base OID the chip is measured against. */ +async function seedParityFixture(repo: string): Promise { + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') + await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) + // Staged and unstaged hunks land on the same added lines, so an area sum + // double-counts them. + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') + runFixtureGit(repo, ['add', 'partial.txt']) + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') + // The branch commit's line, taken back out in the worktree: net zero. + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') + return mergeBase +} + +function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { + added: number + removed: number +} { + let added = 0 + let removed = 0 + for (const entry of entries) { + added += entry.added ?? 0 + removed += entry.removed ?? 0 + } + return { added, removed } +} + +describe('branch line total parity between main and relay', () => { + let repo: string + + beforeEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) + }) + + afterEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await rm(repo, { recursive: true, force: true }) + }) + + it('produces identical totals for the same fixture repo', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + // Both sides model the same worktree, so a parity pass on a mismatched + // entry list would be meaningless. + expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( + mainStatus.entries.map((entry) => entry.path).sort() + ) + }) + + it('agrees on a number no per-area row sum could produce', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) + expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( + AREA_ROW_SUM + ) + expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + }) + + it('omits the total on both sides when no merge base is requested', async () => { + await seedParityFixture(repo) + + const mainStatus = await getStatus(repo) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) + + expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) + expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 120c84869d2..c03d9307112 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + computeGitBranchLineTotal, + invalidateGitBranchLineTotalInFlight, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../shared/git-branch-line-total' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, @@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadLeaseOwner = new GitStatusReadLeaseOwner() -// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. +// Why: clear every in-flight git read cache; clearing only some would let a post-mutation +// getStatus() join a pre-mutation read and publish it as current. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against; + * omitted means the chip is hidden, so no ranged diff runs at all. */ + branchLineTotalMergeBase?: string /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.wslDistro ?? '', options.includeIgnored === true, options.reuseLineStats === true, + // Why: the result carries a total only for callers who asked, and only for + // this fork point, so a shared lease must never serve one to the other. + options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. @@ -371,16 +386,25 @@ async function runGetStatus( } // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. + let branchLineTotal: GitBranchLineTotal | undefined if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = createBranchLineTotalInput( + worktreePath, + entries, + options, + statusSucceeded + ) + const lineStats = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: options.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(worktreePath, entries, options) + recompute: () => attachLineStats(worktreePath, entries, options), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) }) + branchLineTotal = lineStats.branchLineTotal } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -398,6 +422,7 @@ async function runGetStatus( head, branch, ...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}), ...(statusSucceeded ? { @@ -416,6 +441,43 @@ async function runGetStatus( } } +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f3c91088d0a..bf4aff54f04 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1127,6 +1127,7 @@ export function registerFilesystemHandlers( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string } ): Promise => { @@ -1134,6 +1135,9 @@ export function registerFilesystemHandlers( const options = { includeIgnored: args.includeIgnored ?? false, ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index 3d03ca6a269..a8924924f22 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -3,5 +3,7 @@ export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string signal?: AbortSignal } diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2ec2fbbfc6f..e4c306529ee 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider { ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = + options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 7e13e584c42..0fe273108a5 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional() + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c4ccdf071b6..c30e80f4338 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [ params.includeIgnored === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && signal === undefined ? undefined : { @@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [ ? { bypassEffectiveUpstreamNegativeCache: true } : {}), ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), ...(signal ? { signal } : {}) } return options === undefined diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0dc78393f28..094a93c3484 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2898,6 +2898,8 @@ export type PreloadApi = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e26870868..8b3863dcf7c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3230,6 +3230,7 @@ const api = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 880e690ee54..60ea5913139 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -24,6 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { + readGitBranchLineTotalMergeBaseParam, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -74,11 +79,16 @@ export async function getStatusOp( ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number + branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true + // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. + const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( + params.branchLineTotalMergeBase + ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictOperation = await detectConflictOperation(worktreePath) @@ -89,6 +99,8 @@ export async function getStatusOp( let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 + let statusSucceeded = false + let branchLineTotal: GitBranchLineTotal | undefined try { // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. @@ -118,6 +130,7 @@ export async function getStatusOp( ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly + statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { @@ -173,15 +186,26 @@ export async function getStatusOp( // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = buildBranchLineTotalInput( + git, + worktreePath, + entries, + // Why: a failed scan leaves the untracked list untrustworthy, so the total + // would under-count — omit it rather than publish a confident wrong number. + statusSucceeded ? branchLineTotalMergeBase : undefined, + options.signal + ) + // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. + ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal) - }) + recompute: () => attachLineStats(git, worktreePath, entries, options.signal), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) + })) } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -200,7 +224,8 @@ export async function getStatusOp( branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}) + ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}) } } diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6674500c6f9..0abc9f5d5be 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' const execFileAsync = promisify(execFile) @@ -298,6 +299,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts new file mode 100644 index 00000000000..ceb04351e07 --- /dev/null +++ b/src/relay/git-status-branch-line-total.test.ts @@ -0,0 +1,475 @@ +/** + * Relay-side contract for the status pass's branch line total: the chip is an + * exact number or absent, and costs nothing when nobody asked for it. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' +import type { RelayGitStreamExec } from './git-stdout-stream' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' + +type GitCall = Parameters + +const execFileAsync = promisify(execFile) +const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' +const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' +// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. +const UNTRACKED_FILE = 'notes.md' +const UNTRACKED_LINES = 4 +const STATUS_OUTPUT = [ + '# branch.oid 1111111111111111111111111111111111111111', + '# branch.head (detached)', + '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + `? ${UNTRACKED_FILE}` +].join('\n') + +function isRangedNumstat(args: string[]): boolean { + return args.includes('diff') && args.includes('--numstat') && args.includes('-z') +} + +function rangedDiffCalls(calls: readonly GitCall[]): string[][] { + return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) +} + +function streamGitFromCapture(git: GitExec): RelayGitStreamExec { + return async (args, cwd, options) => { + const { stdout } = await git(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } + } +} + +/** Mock host; the fixture-repo cases below run real git instead. */ +function createMockGit(overrides: { + status?: string + ranged?: () => Promise<{ stdout: string; stderr: string }> + areaNumstat?: string +}) { + return vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) +} + +const realGitExec: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** + * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the + * file the branch deleted, +4 untracked. The pure rename contributes nothing. + */ +async function seedBranchFixture(repo: string): Promise { + await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') + await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') + await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') + await fs.rm(path.join(repo, 'doomed.txt')) + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') + await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + return mergeBase +} + +describe('getStatusOp branch line total', () => { + let tmpDir: string + + beforeEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) + await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + }) + + afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('runs no ranged diff and returns no total when the merge base param is absent', async () => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) + + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + // The per-area numstats the CHANGES rows need still ran, so this is a + // zero-cost omission rather than a disabled status pass. + expect(result.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + }) + + it('sums the ranged diff and untracked additions against a real fixture repo', async () => { + const mergeBase = await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: mergeBase + }) + + expect(result.branchLineTotal).toEqual({ + added: 3 + UNTRACKED_LINES, + removed: 2, + mergeBase + }) + expect(rangedDiffCalls(git.mock.calls)).toEqual([ + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + ]) + }) + + it('omits the total when the status listing hit its limit', async () => { + const manyEntries = Array.from( + { length: 6 }, + (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` + ).join('\n') + const git = createMockGit({ status: manyEntries }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + limit: 3, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.didHitLimit).toBe(true) + // The untracked list is truncated, so any total would silently under-count. + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + it('omits the total, never zero, when the ranged diff fails', async () => { + const git = createMockGit({ + ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // A failed ranged diff must not take the per-file rows down with it. + expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) + }) + + it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { + await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + }) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.entries.length).toBeGreaterThan(0) + }) + + it('omits the total when the ranged diff exceeds its time budget', async () => { + const git = createMockGit({ + ranged: () => { + const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') + error.killed = true + return Promise.reject(error) + } + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // The budget is handed to the subprocess rather than raced on a wall clock. + const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] + expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) + }) + + it('omits the total when the status scan itself failed', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + throw new Error('fatal: not a git repository') + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.entries).toEqual([]) + // A failed scan yields an empty untracked list, which would read as "no + // untracked additions" rather than "unknown". + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + // Relay params are an untyped bag, so the merge base must be proven to be an + // object name before it can be spliced into an argv. + it.each([ + ['a flag-shaped value', '--upload-pack=x'], + ['a rev name', 'HEAD'], + ['a ref path', 'refs/heads/main'], + ['a range', `${MERGE_BASE}..HEAD`], + ['a number', 123], + ['null', null], + ['an empty string', ''] + ])('rejects %s before it reaches a git argv', async (_label, value) => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: value + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + for (const [args] of git.mock.calls) { + expect(args).not.toContain(String(value)) + } + }) + + it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { + const gitWithout = createMockGit({}) + const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { + worktreePath: tmpDir + }) + clearGitStatusLineStatsCache() + const gitRejected = createMockGit({}) + const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { + worktreePath: tmpDir, + branchLineTotalMergeBase: '--upload-pack=x' + }) + + expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) + expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( + gitWithout.mock.calls.map(([args]) => args) + ) + }) + + it('rejects an aborted scan instead of resolving a partial total', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + controller.abort() + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await expect( + getStatusOp( + git, + streamGitFromCapture(git), + { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, + { signal: controller.signal } + ) + ).rejects.toThrow(/abort/i) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('reuses the cached total instead of re-running the ranged diff', async () => { + const git = createMockGit({}) + + const first = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE, + reuseLineStats: true + }) + + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + expect(reused.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('coalesces concurrent status passes onto one ranged diff', async () => { + // The renderer's own in-flight refs only dedupe one renderer; a second + // window or an fs-watcher burst must not run the ranged diff twice. + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const [first, second] = await Promise.all([ + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }), + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + ]) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(first.branchLineTotal).toEqual(second.branchLineTotal) + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + }) + + it('recomputes rather than reusing a total measured against another fork point', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const moved = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: OTHER_MERGE_BASE, + reuseLineStats: true + }) + + expect(moved.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: OTHER_MERGE_BASE + }) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) + }) + + // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field + // is safe only while every reader survives its absence. + describe('wire compatibility', () => { + it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { + const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + // An old server's payload and a new server's "not known exact" payload are + // the same thing on the wire: no key at all. + const overTheWire = JSON.parse(JSON.stringify(result)) as { + branchLineTotal?: { added: number; removed: number } + } + + expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) + expect(overTheWire.branchLineTotal).toBeUndefined() + expect(overTheWire.branchLineTotal?.added).toBeUndefined() + expect(overTheWire.branchLineTotal?.removed).toBeUndefined() + }) + + it('keeps the new request param out of the status argv an old server would run', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + const statusArgs = git.mock.calls + .map(([args]) => args) + .filter((args) => args.includes('status')) + expect(statusArgs).toHaveLength(1) + expect(statusArgs[0]).not.toContain(MERGE_BASE) + }) + + it('publishes finite counts when the ranged diff reports only binaries', async () => { + const git = createMockGit({ + ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.branchLineTotal).toEqual({ + added: UNTRACKED_LINES, + removed: 0, + mergeBase: MERGE_BASE + }) + expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) + expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) + }) + }) +}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts new file mode 100644 index 00000000000..c2fe6556a0e --- /dev/null +++ b/src/relay/git-status-branch-line-total.ts @@ -0,0 +1,44 @@ +/** + * Relay-side wiring for the status pass's branch line total. + * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). + */ +import { + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import type { GitExec } from './git-handler-ops' + +/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ +export function buildBranchLineTotalInput( + git: GitExec, + worktreePath: string, + entries: Record[], + mergeBase: string | undefined, + signal?: AbortSignal +): { mergeBase: string; compute: () => Promise } | undefined { + if (!mergeBase) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Matches the `relay\0` line-stats cache-key convention. + hostKey: 'relay', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string), + runDiffNumstat: (args, diffSignal) => + git(args, worktreePath, { + // Why: a working-tree diff must not take index.lock away from terminal Git. + disableOptionalLocks: true, + signal: diffSignal, + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then(({ stdout }) => stdout), + ...(signal ? { signal } : {}) + }) + } +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx new file mode 100644 index 00000000000..c9a7f24beb2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitBranchCompareSummary } from '../../../../shared/types' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import SourceControl from './SourceControl' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as string + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'head-1', + branch: 'refs/heads/feature/line-total', + isBare: false, + isMainWorktree: false, + displayName: 'feature/line-total', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined), + refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined) +})) + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/main', + baseOid: 'base-oid', + compareRef: 'feature/line-total', + headOid: 'head-1', + mergeBase: MERGE_BASE, + changedFiles: 2, + commitsAhead: 1, + status: 'ready' +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.activeRepo.kind = 'git' + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary }, + gitBranchLineTotalByWorktree: {}, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + openFiles: [], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + clearGitBranchCompare: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + clearBranchLineTotalRequestGateForTests() + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + clearBranchLineTotalRequestGateForTests() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function chip(): HTMLElement | null { + return container.querySelector('[data-testid="source-control-branch-line-total"]') +} + +describe('SourceControl branch line total request gate', () => { + it('asks for a total when the panel is visible and compare is ready', () => { + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + }) + + it('asks for nothing while the sidebar is closed', () => { + resetState({ rightSidebarOpen: false }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing while another right-sidebar tab is showing', () => { + resetState({ rightSidebarTab: 'checks' }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing until branch compare is ready', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing when compare has no merge base', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing in a folder workspace', () => { + resetState() + mocks.activeRepo.kind = 'folder' + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('releases the gate when the panel unmounts', () => { + renderSourceControl() + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + + act(() => root.render({null})) + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) +}) + +describe('SourceControl branch line total chip', () => { + it('renders a total measured against the current fork point', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions') + // Grouping is pinned to the app locale (`en`), not the runner's host locale. + expect(chip()?.textContent).toBe('+8,259-670') + }) + + it('drops a total whose fork point has since moved', () => { + // Why: status and branch compare refresh on different cadences, so a total + // can outlive the merge base it measured. Hidden beats a stale number. + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('drops a published total while branch compare has no ready summary', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' } + }, + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing when no total was published', () => { + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing for an exact zero total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('omits the zero half of a one-sided total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.textContent).toBe('+42') + expect(chip()?.getAttribute('aria-label')).toBe('42 additions') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx index 8624d25fae2..d3125ee86c7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -104,6 +104,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 22bc4954b20..13dc2479894 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -156,6 +156,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 68b55f9bda7..68cabc5e044 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -296,6 +296,7 @@ import { } from './source-control-hosted-review-push-target' import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url' import { parseRemoteRepo } from './source-control-remote-repo' +import { setBranchLineTotalMergeBase } from './branch-line-total-request-gate' export { HostedReviewHeaderLink } from './hosted-review-header-chrome' import { createRunningCommitMessageGenerationRecord, @@ -835,6 +836,15 @@ function SourceControlInner(): React.JSX.Element { const branchSummary = useAppStore((s) => activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null ) + const publishedBranchLineTotal = useAppStore((s) => + activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null + ) + // Why: status and branch compare refresh on different cadences, so a total can + // outlive the fork point it measured. Drop it rather than render a stale number. + const branchLineTotal = + publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase + ? publishedBranchLineTotal + : null const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) @@ -1252,6 +1262,22 @@ function SourceControlInner(): React.JSX.Element { // Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer. const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen + // Why: the merge base IS the request gate — no OID on the status request means + // the host runs no ranged diff, so a hidden chip costs a background worktree nothing. + const requestedBranchLineTotalMergeBase = + isBranchVisible && !isFolder && branchSummary?.status === 'ready' + ? branchSummary.mergeBase + : null + useEffect(() => { + if (!activeWorktreeId) { + return + } + setBranchLineTotalMergeBase(activeWorktreeId, requestedBranchLineTotalMergeBase) + return () => { + setBranchLineTotalMergeBase(activeWorktreeId, null) + } + }, [activeWorktreeId, requestedBranchLineTotalMergeBase]) + const refreshActiveGitStatus = useCallback( async (signal?: AbortSignal): Promise => { if (!activeWorktreeId || !worktreePath || isFolder) { @@ -5549,6 +5575,7 @@ function SourceControlInner(): React.JSX.Element { diffCommentCount={diffCommentCount} onExpandNotes={() => setDiffCommentsExpanded(true)} branchSummary={branchSummary} + branchLineTotal={branchLineTotal} compareBaseRef={compareBaseRef} headDisplay={gitIdentityDisplay} upstreamStatus={remoteStatus} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx index 7e1d4c7d2ec..5ff61b17d43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -116,6 +116,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts new file mode 100644 index 00000000000..600a3cf8fc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' + +describe('branch line total request gate', () => { + beforeEach(() => { + clearBranchLineTotalRequestGateForTests() + }) + + it('reports no merge base for a worktree that never opened the chip', () => { + expect(getBranchLineTotalMergeBase('wt-never-gated')).toBeUndefined() + }) + + it('stores the merge base a visible chip asked for', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-1') + }) + + it('replaces the merge base when the fork point moves', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', 'merge-base-2') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-2') + }) + + it('deletes the entry when the chip is hidden', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('treats an empty merge base as no gate rather than an empty request param', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', '') + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('keeps entries independent per worktree', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-2', 'merge-base-2') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + expect(getBranchLineTotalMergeBase('wt-2')).toBe('merge-base-2') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts new file mode 100644 index 00000000000..c3fa7b98369 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts @@ -0,0 +1,23 @@ +// Why: several independent code paths call `git.status`; if only some carried +// the merge base the branch line-total chip would blank out between polls. One +// registry keeps every path asking for the same thing. +// Only the visible worktree's SourceControl is mounted and its effect cleanup +// deletes on every change, so this holds ~1 entry and needs no eviction. +const branchLineTotalMergeBaseByWorktree = new Map() + +/** `null` deletes the entry — that absence is the visibility gate (no merge base ⇒ no host cost). */ +export function setBranchLineTotalMergeBase(worktreeId: string, mergeBase: string | null): void { + if (!mergeBase) { + branchLineTotalMergeBaseByWorktree.delete(worktreeId) + return + } + branchLineTotalMergeBaseByWorktree.set(worktreeId, mergeBase) +} + +export function getBranchLineTotalMergeBase(worktreeId: string): string | undefined { + return branchLineTotalMergeBaseByWorktree.get(worktreeId) +} + +export function clearBranchLineTotalRequestGateForTests(): void { + branchLineTotalMergeBaseByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts new file mode 100644 index 00000000000..3437d8d227a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult } from '../../../../shared/types' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +function makeDeps(): GitStatusRefreshDeps { + return { + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + setUpstreamStatus: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) + } +} + +function stubGitStatus(): ReturnType { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + cancelStatus: vi.fn().mockResolvedValue(undefined), + upstreamStatus: vi.fn().mockResolvedValue({ hasUpstream: false, ahead: 0, behind: 0 }) + } + } + }) + return gitStatus +} + +describe('branch line total request gate on git status refreshes', () => { + beforeEach(() => { + vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() + clearBranchLineTotalRequestGateForTests() + }) + + it('omits the merge base when no chip is asking for a total', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-hidden', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + // Why: no OID on the request is the whole performance contract — the host + // runs no ranged diff, so a background worktree costs nothing. + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + }) + + it('omits the merge base for a worktree whose chip was hidden again', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + setBranchLineTotalMergeBase('wt-1', null) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + }) + + it('does not send one worktree gate on another worktree status pass', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-visible', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-background', + worktreePath: '/other-repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/other-repo', + connectionId: undefined + }) + }) + + it('sends the merge base on a plain refresh that passes no request options', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + // Why: this path used to build no options object at all, so the gate was + // silently dropped on the most common (fs-watcher) refresh. + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base alongside line-stat reuse', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { reuseLineStats: true } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base on an abortable refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + const controller = new AbortController() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { signal: controller.signal } + }) + + expect(gitStatus).toHaveBeenCalledWith( + expect.objectContaining({ + worktreePath: '/repo', + branchLineTotalMergeBase: MERGE_BASE + }) + ) + }) + + it('re-reads the gate on every pass so a moved fork point is not sent twice', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base') + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: MERGE_BASE + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: 'rebased-merge-base' + }) + }) + + it('omits the merge base from a strict refresh when no chip is visible', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + // Why: a strict refresh is exactly the commit/push/sync that moves the number; + // skipping the gate there would blank the chip until the next automatic poll. + it('sends the merge base on a strict refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-strict', MERGE_BASE) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 596a68f8df3..50f2cf068c7 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' +import { getBranchLineTotalMergeBase } from './branch-line-total-request-gate' import { clearAutomaticPushTargetUpstreamStatusCache, getCachedAutomaticPushTargetUpstreamStatus, @@ -121,6 +122,9 @@ export async function refreshGitStatusForWorktree({ } }): Promise { const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) + // Why: every status path must carry the merge base, or the chip blanks out on + // whichever poll happened to omit it. + const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) try { const status = (await getRuntimeGitStatus( { @@ -129,12 +133,11 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId }, - request - ? { - ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(request.signal ? { signal: request.signal } : {}) - } - : undefined + { + ...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } )) as GitStatusResult if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { @@ -247,6 +250,7 @@ export async function refreshGitStatusForWorktreeStrict({ }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { beginStrictUpstreamRefresh(worktreeId) clearAutomaticPushTargetUpstreamStatusCache() + const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) const status = (await getRuntimeGitStatus( { settings, @@ -257,7 +261,12 @@ export async function refreshGitStatusForWorktreeStrict({ { // Why: strict refreshes are user-triggered reconciliation and must not reuse // automatic polling's no-upstream backoff window. - bypassEffectiveUpstreamNegativeCache: true + bypassEffectiveUpstreamNegativeCache: true, + // Why: an absent total clears the chip, so a path that skipped the gate + // would blank it right after the commit or push that moved the number. + ...(strictBranchLineTotalMergeBase + ? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase } + : {}) } )) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 4a454224997..0172dab5dd0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlBranchContextRow } from './source-control-branch-context-row' import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchLineTotal } from '../../../../shared/git-status-types' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -215,3 +216,144 @@ describe('SourceControlBranchContextRow', () => { expect(markup).toContain('aria-label="Open review page in browser"') }) }) + +function renderWithLineTotal( + branchLineTotal: GitBranchLineTotal | null | undefined, + summary: GitBranchCompareSummary | null = readySummary +): string { + return renderToStaticMarkup( + + ) +} + +describe('SourceControlBranchContextRow branch line total', () => { + it('renders both halves with grouped digits and a spoken label', () => { + const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' }) + + expect(markup).toContain('+8,259') + expect(markup).toContain('-670') + // Label reads raw digits; the grouped spans are decoration. + expect(markup).toContain('aria-label="8259 additions, 670 deletions"') + expect(markup).toContain('tabular-nums') + expect(markup).toContain('text-[color:var(--git-decoration-added)]') + expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') + // Not clickable in v1 — the chip's scope differs from openBranchAllDiffs. + expect(markup).not.toContain(' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 98972cf0760..29218566eb8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14867,7 +14867,7 @@ "other": "Other…", "otherPlaceholder": "Type your answer", "cancel": "Cancel", - "send": "Send answer", + "send": "Submit", "next": "Next", "skip": "Skip", "sending": "Sending…" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 68a9bcb8fbd..e32c1ca2318 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14526,7 +14526,7 @@ "other": "Otro…", "otherPlaceholder": "Escribe tu respuesta", "cancel": "Cancelar", - "send": "Enviar respuesta", + "send": "Enviar", "next": "Siguiente", "skip": "Omitir", "sending": "Enviando…" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ef5a69cbae..16a92c12c02 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14526,7 +14526,7 @@ "other": "他の…", "otherPlaceholder": "答えを入力してください", "cancel": "キャンセル", - "send": "回答を送信する", + "send": "送信", "next": "次へ", "skip": "スキップ", "sending": "送信中…" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6c3e34fcd1..0489f18507a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14537,7 +14537,7 @@ "other": "다른…", "otherPlaceholder": "답변을 입력하세요", "cancel": "취소", - "send": "답변 보내기", + "send": "제출", "next": "다음", "skip": "건너뛰기", "sending": "전송 중…" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c5f255fe4e9..1b197b3eaac 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14546,7 +14546,7 @@ "other": "其他…", "otherPlaceholder": "输入你的回答", "cancel": "取消", - "send": "发送回答", + "send": "提交", "next": "下一步", "skip": "跳过", "sending": "正在发送…" diff --git a/tests/e2e/native-chat-ask-user-question-card.spec.ts b/tests/e2e/native-chat-ask-user-question-card.spec.ts index a4cc2d7c4aa..a3788e5aa96 100644 --- a/tests/e2e/native-chat-ask-user-question-card.spec.ts +++ b/tests/e2e/native-chat-ask-user-question-card.spec.ts @@ -146,10 +146,10 @@ test.describe('Desktop chat AskUserQuestion card (#11761)', () => { // The submit button reads "Skip" until an option is chosen; picking one is // what proves the card is answerable rather than merely rendered. await orcaPage.getByRole('button', { name: /Spaces/ }).click() - await expect(orcaPage.getByRole('button', { name: 'Send answer' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Submit' })).toBeVisible() await orcaPage.screenshot({ path: path.join(screenshotDir, '02-option-selected.png') }) - await orcaPage.getByRole('button', { name: 'Send answer' }).click() + await orcaPage.getByRole('button', { name: 'Submit' }).click() // The card owns the composer slot, so its disappearance is the visible // signal that the answer was accepted and chat input came back. await expect(orcaPage.getByText(QUESTION)).toHaveCount(0, { timeout: 20_000 }) From ae1ed5e886acdc4352e5c2de70fed04331386daa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:29:46 -0700 Subject: [PATCH 12/88] Remove source control group order preference (#12785) * Reorder source control to show staged changes first by default Stages are closest to the commit action and most relevant to the commit workflow. Merges untracked files into Changes visually while preserving their Git area. Removes the untracked-first preset and includes migration logic for existing user settings. * Drop source control group order user preference Remove the sourceControlGroupOrder setting and related UI, migrations, and persistence logic. The source control view now always displays sections in the order: staged changes, unstaged changes, untracked files. * Reorder source control to show changes before staged Aligns with the edit-stage-commit workflow by showing unstaged changes (active edits) before staged changes (queued for commit). --- .oxlintrc.json | 7 +- .../scripts/relay-watcher-fault-harness.mjs | 22 ++--- .../source-control/mobile-git-status.test.ts | 7 +- .../src/source-control/mobile-git-status.ts | 10 +- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/persistence.test.ts | 49 +++------- src/main/persistence.ts | 36 +++---- src/main/runtime/orca-runtime.ts | 5 +- .../editor/CombinedDiffFileTree.tsx | 53 ++++++---- .../GitHubMarkdownComposerEditorPane.tsx | 5 +- .../right-sidebar/SourceControl.tsx | 98 +++++++++++++------ .../discard-all-sequence.test.ts | 9 ++ .../right-sidebar/discard-all-sequence.ts | 5 +- ...ource-control-discard-confirmation.test.ts | 9 ++ .../source-control-discard-confirmation.ts | 19 +++- .../source-control-discard-dialog.tsx | 13 ++- .../source-control-section-order.test.ts | 60 ++++++------ .../source-control-section-order.ts | 28 +++--- .../src/components/settings/GitPane.test.ts | 70 +------------ .../src/components/settings/GitPane.tsx | 91 +---------------- .../src/components/settings/git-search.ts | 27 ----- src/renderer/src/i18n/locales/en.json | 19 ++-- src/renderer/src/i18n/locales/es.json | 12 --- src/renderer/src/i18n/locales/ja.json | 12 --- src/renderer/src/i18n/locales/ko.json | 12 --- src/renderer/src/i18n/locales/zh.json | 12 --- src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 2 - src/shared/source-control-group-order.test.ts | 20 ---- src/shared/source-control-group-order.ts | 9 -- src/shared/types.ts | 3 - ...script-prompt-unreadable-orca-yaml.spec.ts | 5 +- 33 files changed, 267 insertions(+), 468 deletions(-) delete mode 100644 src/shared/source-control-group-order.test.ts delete mode 100644 src/shared/source-control-group-order.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index a9e2b554f91..007fb317ce9 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual([ - 'Changes', - 'Untracked Files', - 'Staged Changes' - ]) + expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) + expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 65fcf61e211..2d7b0795918 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,7 +59,13 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + data: entries + .filter((entry) => + area === 'unstaged' + ? entry.area === 'unstaged' || entry.area === 'untracked' + : entry.area === area + ) + .sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa823..f802fecb7e7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -105,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 47fd0c67850..79bd2ea7bbe 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -97,7 +97,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81faa5f6297..24cc6e512cc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,7 +676,6 @@ describe('Store', () => { const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) - expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') @@ -2784,21 +2783,6 @@ describe('Store', () => { expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) - it('normalizes malformed source control group order on load', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [], - worktreeMeta: {}, - settings: { sourceControlGroupOrder: 'tracked-first' }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - }) - it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5817,15 +5801,21 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { + it('drops retired Source Control order preferences', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + settings: { + ...getDefaultPersistedState(testState.dir).settings, + sourceControlGroupOrder: 'changes-first', + sourceControlHierarchyDefaultedV2: true + } + } as never) + const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) - expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') - - store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') + expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') }) it('updateSettings normalizes terminal shortcut policy', async () => { @@ -5883,18 +5873,15 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) + store.updateSettings({ sourceControlViewMode: 'tree' }) store.flush() const persisted = readDataFile() as { - settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } + settings?: { sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') - expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession @@ -5906,13 +5893,9 @@ describe('Store', () => { expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) - expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ - 'settings.sourceControlGroupOrder' - ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') - expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2afccf7e0ae..af82bcbdd89 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -182,7 +182,6 @@ import { } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { @@ -650,12 +649,22 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripLegacyTerminalScrollbackBytes( +function stripRetiredSettingsFields( settings: Partial | undefined ): Partial { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? - {}) as Partial & { terminalScrollbackBytes?: unknown } + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + sourceControlGroupOrder: _sourceControlGroupOrder, + sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, + ...rest + } = (settings ?? {}) as Partial & { + terminalScrollbackBytes?: unknown + sourceControlGroupOrder?: unknown + sourceControlHierarchyDefaultedV2?: unknown + } void _legacyScrollbackBytes + void _sourceControlGroupOrder + void _sourceControlHierarchyDefaultedV2 return rest } @@ -3316,15 +3325,6 @@ export class Store { ) { this.loadNeedsSave = true } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } result = { ...defaults, ...parsed, @@ -3346,7 +3346,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripRetiredSettingsFields(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3420,7 +3420,6 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5691,7 +5690,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripRetiredSettingsFields(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5759,11 +5758,6 @@ export class Store { updates.terminalShortcutPolicy ) } - if ('sourceControlGroupOrder' in updates) { - sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( - updates.sourceControlGroupOrder - ) - } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e23498f370..b85bb01b463 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10762,10 +10762,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index fc59c2dd75c..dd6f7b99b93 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,7 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, @@ -22,6 +22,11 @@ import { import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' +import { + mergeUntrackedIntoChanges, + SOURCE_CONTROL_GROUP_ORDER, + type SourceControlEntryGroups +} from '@/components/right-sidebar/source-control-section-order' export { createCombinedDiffSectionIndexMap, @@ -30,7 +35,6 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' -const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', staged: 'Staged Changes', @@ -39,26 +43,35 @@ const UNCOMMITTED_AREA_LABELS: Record = { function buildUncommittedRows( entries: readonly CombinedDiffFileTreeEntry[], - collapsedDirectoryKeys: ReadonlySet + collapsedDirectoryKeys: ReadonlySet, + areaOrder: readonly GitStagingArea[] ): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] { - return UNCOMMITTED_AREA_ORDER.map((area) => { - const areaEntries = entries.filter( - (entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area - ) - if (areaEntries.length === 0) { - return null + const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] } + for (const entry of entries) { + if (isGitStatusEntry(entry)) { + groups[entry.area].push(entry) } + } + const displayGroups = mergeUntrackedIntoChanges(groups) - const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) - return { - area, - label: UNCOMMITTED_AREA_LABELS[area], - rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] - } - }).filter( - (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => - Boolean(group) - ) + return areaOrder + .map((area) => { + const areaEntries = displayGroups[area] + if (areaEntries.length === 0) { + return null + } + + const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) + return { + area, + label: UNCOMMITTED_AREA_LABELS[area], + rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] + } + }) + .filter( + (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => + Boolean(group) + ) } function buildBranchRows( @@ -153,7 +166,7 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => mode === 'all' || mode === 'uncommitted' - ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER) : [], [collapsedDirectoryKeys, filteredEntries, mode] ) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
entry.area === 'untracked') } } @@ -1060,7 +1067,6 @@ function SourceControlInner(): React.JSX.Element { settings?.sourceControlViewMode ) const sourceControlViewMode = persistedSourceControlViewMode - const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder) const [collapsedTreeDirs, setCollapsedTreeDirs] = useState>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(null) @@ -1796,13 +1802,19 @@ function SourceControlInner(): React.JSX.Element { [fileFilterState, grouped] ) + const mergedGrouped = useMemo(() => mergeUntrackedIntoChanges(grouped), [grouped]) + const mergedFilteredGrouped = useMemo( + () => mergeUntrackedIntoChanges(filteredGrouped), + [filteredGrouped] + ) + const displaySections = useMemo( - () => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder), - [filteredGrouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedFilteredGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedFilteredGrouped] ) const unfilteredDisplaySections = useMemo( - () => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder), - [grouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedGrouped] ) const unfilteredDisplaySectionsById = useMemo( () => new Map(unfilteredDisplaySections.map((section) => [section.id, section])), @@ -5400,7 +5412,9 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } @@ -5472,7 +5486,7 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, worktreePath, activeWorktreeId, - grouped, + mergedGrouped, isExecutingBulk, clearSelection, discardMany, @@ -5486,13 +5500,21 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } - setPendingDiscard({ kind: 'area', area, paths }) + setPendingDiscard({ + kind: 'area', + area, + paths, + hasUntracked: + area === 'unstaged' && mergedGrouped.unstaged.some((entry) => entry.area === 'untracked') + }) }, - [activeWorktreeId, grouped, isExecutingBulk, worktreePath] + [activeWorktreeId, isExecutingBulk, mergedGrouped, worktreePath] ) const requestDiscardEntry = useCallback( @@ -5914,7 +5936,8 @@ function SourceControlInner(): React.JSX.Element { .filter(isStageableStatusEntry) .map((entry) => entry.path) const unstageAllPaths = getUnstageAllPaths(actionItems) - const discardAllPaths = getDiscardAllPaths(actionItems, area) + const discardAllPaths = getDiscardAllPaths(actionItems, discardAllAreaFilter(area)) + const discardHasUntracked = actionItems.some((entry) => entry.area === 'untracked') const canStageAll = !normalizedFilter && stageAllPaths.length > 0 const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0 const canRevertAll = !normalizedFilter && discardAllPaths.length > 0 @@ -5937,7 +5960,7 @@ function SourceControlInner(): React.JSX.Element {
{canRevertAll && ( { event.stopPropagation() @@ -6055,11 +6083,12 @@ function SourceControlInner(): React.JSX.Element { isExecutingBulk={isExecutingBulk} isCollapsed={collapsedTreeDirs.has(node.key)} onToggle={() => toggleTreeDir(node.key)} - onRequestDiscardPaths={(discardArea, paths) => + onRequestDiscardPaths={(discardArea, paths, hasUntracked) => setPendingDiscard({ kind: 'area', area: discardArea, - paths + paths, + hasUntracked }) } onStagePaths={handleStageAllPaths} @@ -7698,7 +7727,11 @@ function SourceControlTreeDirectoryRow({ isExecutingBulk: boolean isCollapsed: boolean onToggle: () => void - onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void + onRequestDiscardPaths: ( + area: DiscardAllArea, + paths: readonly string[], + hasUntracked: boolean + ) => void onStagePaths: (paths: readonly string[]) => Promise onUnstagePaths: (paths: readonly string[]) => Promise }): React.JSX.Element { @@ -7737,21 +7770,30 @@ function SourceControlTreeDirectoryRow({
{canDiscard && ( { event.stopPropagation() - onRequestDiscardPaths(node.area, actionPaths.discardPaths) + onRequestDiscardPaths( + node.area, + actionPaths.discardPaths, + actionPaths.discardHasUntracked + ) }} disabled={isExecutingBulk} /> diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index e808af6da27..d8f76c48f3f 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -30,6 +30,15 @@ describe('getDiscardAllPaths', () => { expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts']) }) + it('accepts mixed Changes areas when untracked files are combined', () => { + const entries: GitStatusEntry[] = [ + entry({ path: 'changed.ts', area: 'unstaged' }), + entry({ path: 'new.ts', area: 'untracked', status: 'untracked' }), + entry({ path: 'ready.ts', area: 'staged' }) + ] + expect(getDiscardAllPaths(entries, ['unstaged', 'untracked'])).toEqual(['changed.ts', 'new.ts']) + }) + it('skips entries with an unresolved conflict', () => { const entries: GitStatusEntry[] = [ entry({ path: 'clean.ts', area: 'unstaged' }), diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index c52854fcd91..073ba321a2a 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -9,12 +9,13 @@ export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' */ export function getDiscardAllPaths( entries: readonly GitStatusEntry[], - area: DiscardAllArea + area: DiscardAllArea | readonly DiscardAllArea[] ): string[] { + const areas = Array.isArray(area) ? area : [area] return entries .filter( (entry) => - entry.area === area && + areas.includes(entry.area) && entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 48e28160af6..6f6632feea8 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -111,4 +111,13 @@ describe('getDiscardAreaConfirmationCopy', () => { confirmLabel: 'Discard all' }) }) + + it('discloses permanent deletion for combined Changes', () => { + expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({ + title: 'Discard changes and delete untracked files?', + description: + 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.', + confirmLabel: 'Delete and discard' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts index ff020c434ec..03a6805e7f0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts @@ -62,8 +62,25 @@ export function getDiscardEntryConfirmationCopy( export function getDiscardAreaConfirmationCopy( area: DiscardAllArea, - count: number + count: number, + hasUntracked = false ): DiscardConfirmationCopy { + if (area === 'unstaged' && hasUntracked) { + return { + title: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle', + 'Discard changes and delete untracked files?' + ), + description: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription', + 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.' + ), + confirmLabel: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm', + 'Delete and discard' + ) + } + } switch (area) { case 'untracked': return { diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx index 6741a72ac55..25e2366b616 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx @@ -20,7 +20,12 @@ import { translate } from '@/i18n/i18n' export type PendingDiscardConfirmation = | { kind: 'entry'; entry: GitStatusEntry } - | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] } + | { + kind: 'area' + area: DiscardAllArea + paths: readonly string[] + hasUntracked?: boolean + } export function focusDiscardDialogConfirmButton( event: Event, @@ -51,7 +56,11 @@ export function SourceControlDiscardDialog({ if (pendingDiscard.kind === 'entry') { return getDiscardEntryConfirmationCopy(pendingDiscard.entry) } - return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length) + return getDiscardAreaConfirmationCopy( + pendingDiscard.area, + pendingDiscard.paths.length, + pendingDiscard.hasUntracked + ) }, [pendingDiscard]) const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2 diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts index fa563470d66..91378833b0d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts @@ -4,7 +4,8 @@ import { buildSourceControlDisplaySections, getConflictReviewEntries, getSourceControlSectionViewAction, - resolveSourceControlGroupOrder, + mergeUntrackedIntoChanges, + SOURCE_CONTROL_GROUP_ORDER, splitPinnedSourceControlConflicts, type SourceControlEntryGroups } from './source-control-section-order' @@ -26,37 +27,38 @@ function groups(partial: Partial): SourceControlEntryG } } -describe('resolveSourceControlGroupOrder', () => { - it('keeps Changes first by default', () => { - expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked']) +describe('SOURCE_CONTROL_GROUP_ORDER', () => { + it('follows the edit, stage, commit workflow', () => { + expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked']) }) +}) - it('supports staged-first and untracked-first presets', () => { - expect(resolveSourceControlGroupOrder('staged-first')).toEqual([ - 'staged', - 'unstaged', - 'untracked' - ]) - expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([ - 'untracked', - 'unstaged', - 'staged' - ]) +describe('mergeUntrackedIntoChanges', () => { + it('folds untracked entries into Changes without changing their Git area', () => { + const unstaged = entry({ area: 'unstaged', path: 'changed.ts' }) + const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' }) + const merged = mergeUntrackedIntoChanges( + groups({ unstaged: [unstaged], untracked: [untracked] }) + ) + + expect(merged.unstaged).toEqual([unstaged, untracked]) + expect(merged.untracked).toEqual([]) + expect(merged.unstaged[1]?.area).toBe('untracked') }) }) describe('buildSourceControlDisplaySections', () => { - it('uses the configured order for normal sections', () => { + it('uses the fixed workflow order for normal sections', () => { const sections = buildSourceControlDisplaySections( groups({ staged: [entry({ area: 'staged', path: 'staged.ts' })], unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) - expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked']) + expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked']) }) it('keeps conflicts pinned before the configured normal order', () => { @@ -74,13 +76,13 @@ describe('buildSourceControlDisplaySections', () => { ], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(sections.map((section) => section.id)).toEqual([ 'conflicts', - 'staged', 'unstaged', + 'staged', 'untracked' ]) }) @@ -100,10 +102,7 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ unstaged: [unresolved, resolved, normal] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections( - input, - resolveSourceControlGroupOrder('changes-first') - ) + const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts']) expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts']) @@ -123,10 +122,7 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ staged: [resolvedStaged, staged] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections( - input, - resolveSourceControlGroupOrder('staged-first') - ) + const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) expect(split.pinnedConflicts).toEqual([resolvedStaged]) expect(split.normalGroups.staged).toEqual([staged]) @@ -167,7 +163,7 @@ describe('buildSourceControlDisplaySections', () => { entry({ area: 'unstaged', path: 'normal.ts' }) ] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -191,7 +187,7 @@ describe('buildSourceControlDisplaySections', () => { const normal = entry({ area: 'unstaged', path: 'normal.ts' }) const sections = buildSourceControlDisplaySections( groups({ unstaged: [pinned, normal] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({ @@ -212,7 +208,7 @@ describe('buildSourceControlDisplaySections', () => { groups({ unstaged: [resolved] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -240,7 +236,7 @@ describe('buildSourceControlDisplaySections', () => { staged: [staged], unstaged: [unstaged] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts index 557db90b556..91cc948855b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -1,5 +1,4 @@ -import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order' -import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/types' export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number] @@ -22,16 +21,23 @@ export type SourceControlSectionViewAction = | { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] } | { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] } -const ORDER_BY_PRESET: Record = { - 'changes-first': ['unstaged', 'staged', 'untracked'], - 'staged-first': ['staged', 'unstaged', 'untracked'], - 'untracked-first': ['untracked', 'unstaged', 'staged'] -} +export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [ + 'unstaged', + 'staged', + 'untracked' +] -export function resolveSourceControlGroupOrder( - value: SourceControlGroupOrder | null | undefined -): readonly SourceControlSectionArea[] { - return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)] +export function mergeUntrackedIntoChanges( + groups: SourceControlEntryGroups +): SourceControlEntryGroups { + if (groups.untracked.length === 0) { + return groups + } + return { + staged: groups.staged, + unstaged: [...groups.unstaged, ...groups.untracked], + untracked: [] + } } export function isPinnedConflictEntry(entry: GitStatusEntry): boolean { diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 3275eb9a183..2947f866fdd 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -6,12 +6,7 @@ import { getDefaultSettings } from '../../../../shared/constants' import { translate } from '../../i18n/i18n' import { useAppStore } from '../../store' import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting' -import { - GitPane, - SourceControlGroupOrderSetting, - getGitPaneSearchEntries, - shouldShowAutoRenameBranchSetting -} from './GitPane' +import { GitPane, getGitPaneSearchEntries, shouldShowAutoRenameBranchSetting } from './GitPane' import { TooltipProvider } from '../ui/tooltip' import { matchesSettingsSearch } from './settings-search' import { SettingsSegmentedControl } from './SettingsFormControls' @@ -40,23 +35,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSegmentedControl(node: unknown): ReactElementLike { - let found: ReactElementLike | null = null - const label = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - visit(node, (entry) => { - if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) { - found = entry - } - }) - if (!found) { - throw new Error('segmented control not found') - } - return found -} - function findCompareBaseSegmentedControl(node: unknown): ReactElementLike { let found: ReactElementLike | null = null const label = translate( @@ -136,52 +114,6 @@ describe('GitPane', () => { expect(markup).not.toContain('Refresh Local Base Ref') }) - it('renders Source Control group order in Git settings', () => { - const markup = renderGitPane('group order') - - expect(markup).toContain( - translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.changesFirst', 'Changes first') - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first') - ) - }) - - it('updates Source Control group order only when the selected option changes', () => { - const updateSettings = vi.fn() - const element = SourceControlGroupOrderSetting({ - settings: { - ...getDefaultSettings(os.homedir()), - sourceControlGroupOrder: 'changes-first' - }, - updateSettings - }) - - const control = findSegmentedControl(element) - const onChange = control.props.onChange as (value: string) => void - - onChange('staged-first') - expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' }) - - updateSettings.mockClear() - onChange('changes-first') - expect(updateSettings).not.toHaveBeenCalled() - }) - - it('includes Source Control group order search metadata', () => { - expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true) - expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true) - }) - it('renders the default compare base setting in Git settings', () => { const markup = renderGitPane('compare base') diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index da1a34c3fe8..538315e3e7d 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/types' import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order' import { Input } from '../ui/input' import { Label } from '../ui/label' import { useAppStore } from '../../store' @@ -20,7 +19,6 @@ import { getKeepLocalMainUpToDateTitle } from './keep-local-main-up-to-date-setting' import { translate } from '@/i18n/i18n' -import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' export { getGitPaneSearchEntries } @@ -40,14 +38,6 @@ const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [ 'safely', 'worktree' ] -const SOURCE_CONTROL_GROUP_ORDER_KEYWORDS = [ - 'group order', - 'changes first', - 'staged first', - 'untracked first', - 'source control', - 'git changes' -] export function shouldShowAutoRenameBranchSetting( searchQuery: string, @@ -70,68 +60,6 @@ type GitPaneProps = { settingsSearchQuery?: string } -export function SourceControlGroupOrderSetting({ - settings, - updateSettings -}: { - settings: GlobalSettings - updateSettings: (updates: Partial) => void | Promise -}): React.JSX.Element { - const value = settings.sourceControlGroupOrder ?? DEFAULT_SOURCE_CONTROL_GROUP_ORDER - const title = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - const description = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ) - - return ( - - - value={value} - onChange={(nextValue) => { - if (nextValue !== value) { - void updateSettings({ sourceControlGroupOrder: nextValue }) - } - }} - ariaLabel={title} - size="sm" - options={[ - { - value: 'changes-first', - label: translate('auto.components.settings.GitPane.changesFirst', 'Changes first') - }, - { - value: 'staged-first', - label: translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') - }, - { - value: 'untracked-first', - label: translate( - 'auto.components.settings.GitPane.untrackedFirst', - 'Untracked first' - ) - } - ]} - /> - } - /> - - ) -} - export function GitPane({ settings, updateSettings, @@ -297,23 +225,6 @@ export function GitPane({ ) : null, - matchesSettingsSearch(searchQuery, { - title: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS - }) ? ( - - ) : null, compareAgainstUpstreamMatchesSearch(searchQuery) ? ( [ ...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree') ] }, - { - title: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.changesFirst', - 'changes first' - ), - ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.untrackedFirst', - 'untracked first' - ), - ...translateSearchKeyword( - 'auto.components.settings.git.search.sourceControl', - 'source control' - ), - ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes') - ] - }, { title: translate( 'auto.components.settings.git.search.compareAgainstUpstreamTitle', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 29218566eb8..3f940076736 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6183,11 +6183,6 @@ "3d172725cc": "None", "1f32ba27a6": "Custom", "a182c5125e": "Git Username", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "changesFirst": "Changes first", - "stagedFirst": "Staged first", - "untrackedFirst": "Untracked first", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBaseRepositoryDefault": "Repository default", @@ -8463,14 +8458,7 @@ "f83c8937c4": "branch naming", "5ecd91c5ef": "Prefix added to branch names when creating worktrees.", "68bd65fdb8": "Branch Prefix", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "groupOrder": "group order", - "changesFirst": "changes first", - "stagedFirst": "staged first", - "untrackedFirst": "untracked first", "sourceControl": "source control", - "gitChanges": "git changes", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBase": "compare base", @@ -10805,6 +10793,8 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", + "discardAllMixedUntracked": "Discard changes and delete untracked files", + "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder", "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, @@ -11230,7 +11220,10 @@ "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.", "5c0bdbc4cb": "Restore \"{{value0}}\"?", "d97bf697c9": "This will permanently delete this file. This cannot be undone.", - "96c772bee9": "Delete \"{{value0}}\"?" + "96c772bee9": "Delete \"{{value0}}\"?", + "mixedTitle": "Discard changes and delete untracked files?", + "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.", + "mixedConfirm": "Delete and discard" }, "dialog": { "3bc61dc989": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e32c1ca2318..33b14ec6fa7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5966,11 +5966,6 @@ "3d172725cc": "Ninguno", "1f32ba27a6": "Personalizado", "a182c5125e": "Usuario de Git", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "changesFirst": "Cambios primero", - "stagedFirst": "Cambios preparados primero", - "untrackedFirst": "Archivos sin seguimiento primero", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBaseRepositoryDefault": "Predeterminada del repositorio", @@ -8236,14 +8231,7 @@ "f83c8937c4": "nombres de ramas", "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "groupOrder": "orden de grupos", - "changesFirst": "cambios primero", - "stagedFirst": "cambios preparados primero", - "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", - "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBase": "base de comparación", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 16a92c12c02..24c94d5c823 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5988,11 +5988,6 @@ "3d172725cc": "なし", "1f32ba27a6": "カスタム", "a182c5125e": "Gitのユーザー名", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBaseRepositoryDefault": "リポジトリ既定", @@ -8258,14 +8253,7 @@ "f83c8937c4": "ブランチの命名", "5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。", "68bd65fdb8": "ブランチプレフィックス", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "groupOrder": "グループ順序", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "sourceControl": "ソース管理", - "gitChanges": "git の変更", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBase": "比較基準", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0489f18507a..ead7ea624f4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5951,11 +5951,6 @@ "3d172725cc": "없음", "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBaseRepositoryDefault": "저장소 기본값", @@ -8221,14 +8216,7 @@ "f83c8937c4": "브랜치 이름 지정", "5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.", "68bd65fdb8": "브랜치 접두사", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "groupOrder": "그룹 순서", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "sourceControl": "소스 제어", - "gitChanges": "git 변경 사항", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1b197b3eaac..b64faff2472 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5963,11 +5963,6 @@ "3d172725cc": "没有任何", "1f32ba27a6": "自定义", "a182c5125e": "git用户名", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBaseRepositoryDefault": "仓库默认", @@ -8233,14 +8228,7 @@ "f83c8937c4": "分支命名", "5ecd91c5ef": "创建工作树时添加到分支名称的前缀。", "68bd65fdb8": "分支前缀", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "groupOrder": "分组顺序", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "sourceControl": "源代码管理", - "gitChanges": "git 更改", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e8c55be052d..85780660b2a 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -23,9 +23,7 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') }) - it('keeps Source Control changes first by default', () => { - expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') - }) + it('uses a commit-oriented Source Control layout by default', () => {}) it('defaults mobile pairing to discovered network addresses', () => { expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 80fca73c828..33b927dccf1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,7 +29,6 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' @@ -272,7 +271,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/shared/source-control-group-order.test.ts b/src/shared/source-control-group-order.test.ts deleted file mode 100644 index 165d2359c3b..00000000000 --- a/src/shared/source-control-group-order.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DEFAULT_SOURCE_CONTROL_GROUP_ORDER, - normalizeSourceControlGroupOrder -} from './source-control-group-order' - -describe('normalizeSourceControlGroupOrder', () => { - it('keeps supported source control group orders', () => { - expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first') - expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first') - expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first') - }) - - it('falls back to the default for malformed values', () => { - expect(normalizeSourceControlGroupOrder('tracked-first')).toBe( - DEFAULT_SOURCE_CONTROL_GROUP_ORDER - ) - expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER) - }) -}) diff --git a/src/shared/source-control-group-order.ts b/src/shared/source-control-group-order.ts deleted file mode 100644 index acfe5160ac7..00000000000 --- a/src/shared/source-control-group-order.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SourceControlGroupOrder } from './types' - -export const DEFAULT_SOURCE_CONTROL_GROUP_ORDER: SourceControlGroupOrder = 'changes-first' - -export function normalizeSourceControlGroupOrder(value: unknown): SourceControlGroupOrder { - return value === 'changes-first' || value === 'staged-first' || value === 'untracked-first' - ? value - : DEFAULT_SOURCE_CONTROL_GROUP_ORDER -} diff --git a/src/shared/types.ts b/src/shared/types.ts index 947786da5bc..a3864c39061 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2699,7 +2699,6 @@ export type OpenInApplication = { } export type SourceControlViewMode = 'list' | 'tree' -export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' @@ -2885,8 +2884,6 @@ export type GlobalSettings = { showGitIgnoredFiles?: boolean /** Preferred Source Control changes layout. Per-user, not per-workspace. */ sourceControlViewMode: SourceControlViewMode - /** Preferred Source Control group order. Per-user, not per-workspace. */ - sourceControlGroupOrder: SourceControlGroupOrder /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ sourceControlCompareAgainstUpstream: boolean /** Whether to show the Orca app name in the titlebar. */ diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 5cc07ef0e5b..151a4b02bbc 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,8 +65,9 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) - .__orcaE2eOrcaYamlUnreadable = false + ;( + globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } + ).__orcaE2eOrcaYamlUnreadable = false }) } From 2ff2a1b26870c51ed59d2aa712dc96d6aa11ab27 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:52:49 -0700 Subject: [PATCH 13/88] Display SSH worktrees immediately using persisted metadata (#12646) * Display SSH worktrees immediately using persisted metadata Users can now see known worktrees for SSH hosts without waiting for the provider connection to establish. Worktrees are fetched from local metadata and displayed as non-authoritative, then merged without replacing richer live data once the provider becomes available. * Show SSH folder workspaces immediately via persisted metadata Add safeguards for metadata fallback: track authoritatively removed worktrees per host to prevent resurrection, position new rows within the host block to avoid jumping on authoritative scan arrival, and preserve co-owner detection status during merge. Coalesce concurrent metadata fetches to dedupe overlapping queries. --- src/main/ipc/worktrees.test.ts | 135 +++++- src/main/ipc/worktrees.ts | 84 +++- src/preload/api-types.ts | 5 + src/preload/index.ts | 3 + src/preload/ssh-authority-forwarding.test.ts | 10 + .../slices/degraded-repo-hydration.test.ts | 35 +- .../slices/degraded-repo-worktree-validity.ts | 12 +- .../src/store/slices/worktrees.test.ts | 425 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 223 +++++++++ .../detected-worktree-provider-contract.ts | 18 + 10 files changed, 944 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..f5925e1acc1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const rootId = `${folderRepo.id}::${folderRepo.path}` + const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555` + const metaById = { + [rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }), + [instanceId]: makeWorktreeMeta({ + displayName: 'second workspace', + hostId: sshHostId, + instanceId: '11111111-2222-3333-4444-555555555555' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId + })) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } } + + expect(result.status).toBe('complete') + expect(result.result.authoritative).toBe(false) + // Why: the git-worktree synthesizer would read the "::workspace:" tail as a directory. + expect(result.result.worktrees).toEqual([ + expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), + expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) + ]) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects metadata-only reads for non-SSH execution hosts', async () => { + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + expect(store.getRepos).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..098e1e8191f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,9 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -229,7 +231,8 @@ import { import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { @@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt return index } +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { return { path, @@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees( if (Object.keys(ownershipUpdates).length > 0) { store.setWorktreeMeta(candidate.id, ownershipUpdates) } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, meta), - meta + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 094a93c3484..53bdb0e7349 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -1399,6 +1401,9 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b3863dcf7c..cf602b3f1cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -766,6 +766,9 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..04f13430801 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts index 93902bb9d49..48690c43f51 100644 --- a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -16,7 +16,7 @@ import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession } from '@/lib/workspace-session' -import { createTestStore, makeTab } from './store-test-helpers' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' const WORKTREE_ID = 'repo1::/path/degraded' const TERMINAL_ID = 'terminal-degraded' @@ -199,6 +199,39 @@ it('keeps a terminal-free degraded workspace selected through hydration and pers expect(restored.activeTabType).toBe('browser') }) +it('keeps chrome when a non-authoritative fallback lists only some of the repo worktrees', () => { + const store = createTestStore() + // Why: an SSH metadata fallback can be non-empty yet partial (host-less metas are skipped on multi-owner + // repos, agent-scratch stays hidden). A partial list must not read as proof the missing worktree was deleted. + const sibling = makeWorktree({ + id: 'repo1::/path/sibling', + repoId: 'repo1', + path: '/path/sibling' + }) + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [sibling] }, + detectedWorktreesByRepo: { + repo1: { + repoId: 'repo1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + }) + const session = makeDegradedRepoSession() + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + store.getState().hydrateEditorSession(session) + store.getState().hydrateBrowserSession(session) + + const state = store.getState() + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([TERMINAL_ID]) + expect(state.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID]) + expect(state.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID]) +}) + it('drops terminal-free chrome when an authoritative scan proves deletion', () => { const store = createTestStore() hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true) diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts index 0fb4e42fd19..1a9693bdf5f 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -45,13 +45,21 @@ export function buildValidWorktreeIdsForSessionHydration( .map((worktree) => worktree.id) ) const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id)) + const detectedWorktreesByRepo = catalog.detectedWorktreesByRepo ?? {} const repoIdsWithLoadedWorktrees = new Set( Object.entries(worktreesByRepo) - .filter(([, worktrees]) => worktrees.length > 0) + // Why (#1158): a metadata fallback can be non-empty yet partial (host-less metas are skipped on + // multi-owner repos, agent-scratch stays hidden), so it cannot prove deletion the way a real listing can. + // Only an explicitly non-authoritative result is disqualified; a repo with no detection entry at all + // still counts as loaded, as before. + .filter( + ([repoId, worktrees]) => + worktrees.length > 0 && detectedWorktreesByRepo[repoId]?.authoritative !== false + ) .map(([repoId]) => repoId) ) const repoIdsWithAuthoritativeDetectedWorktrees = new Set( - Object.entries(catalog.detectedWorktreesByRepo ?? {}) + Object.entries(detectedWorktreesByRepo) .filter(([, detected]) => detected?.authoritative) .map(([repoId]) => repoId) ) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6d8fff0696a..72d099a2731 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' @@ -105,12 +107,17 @@ const listDetectedMock = vi.fn< return qualifyDetectedResult(args, result) }) +const listKnownForExecutionHostMock = vi.fn< + (args: ListKnownWorktreesForExecutionHostArgs) => Promise +>(async (args) => ({ status: 'rejected', ...args })) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, listDetected: listDetectedMock, + listKnownForExecutionHost: listKnownForExecutionHostMock, cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), @@ -153,6 +160,7 @@ import { createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, + resetAuthoritativelyRemovedWorktreeMemoryForTests, resetHostedReviewLinkMutationGenerationForTests } from './worktrees' import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation' @@ -439,6 +447,7 @@ describe('fetchWorktrees', () => { vi.clearAllMocks() resetRemoteRuntimeMocks() clearHugeRepoWarningDismissalsForTests() + resetAuthoritativelyRemovedWorktreeMemoryForTests() }) it('does not notify subscribers when the fetched payload is unchanged', async () => { @@ -1350,6 +1359,365 @@ describe('fetchWorktrees', () => { expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) + it('shows persisted secondary worktrees while SSH is connecting', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + const detected = makeDetectedResult('repo-ssh', [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: detected + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + }) + + it('adds metadata rows without replacing richer cached SSH worktrees', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1', + head: 'live-head', + branch: 'refs/heads/live-branch' + }) + const metadataExisting = { ...existing, head: '', branch: '' } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult('repo-ssh', [metadataExisting, queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [existing] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + existing, + { ...queued, hostId: 'ssh:ssh-1' } + ]) + }) + + it('inserts metadata rows inside the SSH block instead of past sibling hosts', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const sshExisting = makeWorktree({ + id: 'repo-shared::/home/orca/existing', + repoId: sshRepo.id, + path: '/home/orca/existing', + hostId: 'ssh:ssh-1' + }) + const localExisting = makeWorktree({ + id: 'repo-shared::/local/existing', + repoId: sshRepo.id, + path: '/local/existing', + hostId: LOCAL_EXECUTION_HOST_ID + }) + const queued = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [sshExisting, localExisting] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + sshExisting, + { ...queued, hostId: 'ssh:ssh-1' }, + localExisting + ]) + }) + + it('drops metadata rows when SSH authority lands during the read', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + // Why: deleted on the host, so an authoritative scan already purged it; the late metadata write must not resurrect it. + const purged = makeWorktree({ + id: 'repo-ssh::/home/orca/purged', + repoId: 'repo-ssh', + path: '/home/orca/purged' + }) + listKnownForExecutionHostMock.mockImplementationOnce(async (args) => { + store.setState({ + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, + connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + } + ] + ]), + worktreesByRepo: { [sshRepo.id]: [live] } + } as Partial) + return { + status: 'complete', + repoId: args.repoId, + executionHostId: args.executionHostId, + result: makeDetectedResult(args.repoId, [live, purged], { + authoritative: false, + source: 'metadata-fallback' + }) + } + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map() + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('replaces metadata rows once the authoritative SSH scan lands', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const stale = makeWorktree({ + id: 'repo-ssh::/home/orca/stale', + repoId: 'repo-ssh', + path: '/home/orca/stale' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [stale], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...stale, hostId: 'ssh:ssh-1' } + ]) + + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('keeps the repo detection entry authoritative while appending metadata rows', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const scanned = makeWorktree({ + id: 'repo-shared::/local/scanned', + repoId: sshRepo.id, + path: '/local/scanned' + }) + const fromMetadata = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + const authoritative = makeDetectedResult(sshRepo.id, [scanned]) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [fromMetadata], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + detectedWorktreesByRepo: { [sshRepo.id]: authoritative } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + const detected = store.getState().detectedWorktreesByRepo[sshRepo.id] + // Why: this entry is shared with the co-owning local host, so the fallback must not demote its scan. + expect(detected?.authoritative).toBe(true) + expect(detected?.source).toBe('git') + expect(detected?.worktrees.map((worktree) => worktree.id)).toEqual([ + scanned.id, + fromMetadata.id + ]) + }) + + it('does not resurrect worktrees an authoritative SSH scan already removed', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const deletedOnRemote = makeWorktree({ + id: 'repo-ssh::/home/orca/deleted', + repoId: 'repo-ssh', + path: '/home/orca/deleted' + }) + const metadataResult = () => ({ + status: 'complete' as const, + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' as const, + result: makeDetectedResult(sshRepo.id, [deletedOnRemote], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...deletedOnRemote, hostId: 'ssh:ssh-1' } + ]) + + // The host connects and an authoritative scan proves the worktree is gone. + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + + // The host drops again; persisted metadata still lists the deleted worktree. + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ sshConnectionStates: new Map() } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -7543,6 +7911,63 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { addedAt: 0 } + it.each([false, true])( + 'hydrates connecting SSH worktrees with hydration purge completed=%s', + async (hasHydratedWorktreePurge) => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + hasHydratedWorktreePurge, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + } + ) + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { const store = createTestStore() const parent = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 8facfa55dc2..3859cec22fc 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -121,6 +121,7 @@ import { import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, ListDetectedWorktreesArgs, ProviderRequestId, SshExecutionHostId @@ -2877,6 +2878,8 @@ function mergeFetchedWorktrees( args: FencedWorktreeMergeArgs ): boolean { let admitted = false + let authoritativelyRemovedIds: readonly string[] = [] + let authoritativelySeenIds: readonly string[] = [] set((s) => { if ( !isCurrentDetectedWorktreeRefresh(s, args.refresh) || @@ -2950,6 +2953,10 @@ function mergeFetchedWorktrees( args.refresh.result, args.hostId ) + authoritativelyRemovedIds = removedIds + if (args.refresh.result.authoritative) { + authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id) + } const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) const detectedChanged = !areDetectedWorktreeResultsEqual( s.detectedWorktreesByRepo[args.repoId], @@ -2979,9 +2986,210 @@ function mergeFetchedWorktrees( ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) + if (admitted) { + // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. + forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) + rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) + } return admitted } +// Why: an authoritative scan is the only proof a remote worktree is gone, but SSH WorktreeMeta is exempt from +// gcStaleWorktreeMeta (persistence.ts:407,415), so without this memory the metadata fallback re-appends every +// deleted row on the next disconnect — forever. +const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512 +const authoritativelyRemovedWorktreeIdsByHost = new Map>() + +function rememberAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: readonly string[] +): void { + if (worktreeIds.length === 0) { + return + } + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set() + for (const worktreeId of worktreeIds) { + // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from. + removed.delete(worktreeId) + removed.add(worktreeId) + } + for (const oldest of removed) { + if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) { + break + } + removed.delete(oldest) + } + authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed) +} + +// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict. +function forgetAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: Iterable +): void { + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) + if (!removed) { + return + } + for (const worktreeId of worktreeIds) { + removed.delete(worktreeId) + } + if (removed.size === 0) { + authoritativelyRemovedWorktreeIdsByHost.delete(hostId) + } +} + +/** Test-only: module-level removal memory would otherwise leak across cases in one file. */ +export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { + authoritativelyRemovedWorktreeIdsByHost.clear() +} + +function appendMissingWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + incoming: readonly T[], + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions +): T[] { + const existing = current ?? [] + const existingHostIds = new Set( + existing + .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) + .map(({ id }) => id) + ) + const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id)) + if (missing.length === 0) { + return [...existing] + } + // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back. + const lastHostIndex = existing.findLastIndex((worktree) => + worktreeMatchesHost(worktree, hostId, options) + ) + if (lastHostIndex === -1) { + return [...existing, ...missing] + } + return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)] +} + +function isAdmittedKnownSshWorktreeResult( + result: HostQualifiedKnownWorktreeResult, + repoId: string, + executionHostId: SshExecutionHostId +): result is Extract { + return ( + result.status === 'complete' && + result.repoId === repoId && + result.executionHostId === executionHostId && + isDetectedWorktreeListResult(result.result) && + result.result.repoId === repoId && + result.result.authoritative === false + ) +} + +const inflightKnownSshWorktreeFetches = new Map< + string, + Promise +>() + +// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard +// the four refresh triggers can each issue this IPC and its merge for the same repo/host. +async function fetchKnownSshWorktreesForRepo( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + const coalesceKey = `${repoId}${executionHostId}` + const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey) + if (inflight) { + return await inflight + } + const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => { + inflightKnownSshWorktreeFetches.delete(coalesceKey) + }) + inflightKnownSshWorktreeFetches.set(coalesceKey, request) + return await request +} + +async function runKnownSshWorktreeFetch( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op. + const listKnown = window.api.worktrees.listKnownForExecutionHost + if (typeof listKnown !== 'function') { + return null + } + const result = await listKnown({ repoId, executionHostId }) + if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { + return null + } + // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone. + const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId) + const known = + suppressedIds && suppressedIds.size > 0 + ? { + ...result.result, + worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id)) + } + : result.result + let admitted = false + set((state) => { + // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees. + if ( + getCurrentDirectSshAuthority(state, executionHostId) || + !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false) + ) { + return state + } + admitted = true + const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) + const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) + const incomingDetected = known.worktrees.map((worktree) => + withRepoHostOwnership(worktree, executionHostId, setup) + ) + const priorDetected = state.detectedWorktreesByRepo[repoId] + // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's + // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface. + const detected = { + ...(priorDetected ?? known), + worktrees: appendMissingWorktreesForHost( + priorDetected?.worktrees, + incomingDetected, + executionHostId, + matchOptions + ) + } + const worktrees = appendMissingWorktreesForHost( + state.worktreesByRepo[repoId], + toVisibleWorktrees(known, executionHostId, setup), + executionHostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + state.detectedWorktreesByRepo[repoId], + detected + ) + if (!worktreesChanged && !detectedChanged) { + return state + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, + sortEpoch: state.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } + : {}) + } + }) + return admitted ? known : null +} + export type DirectSshDetectedWorktreeRefresh = { waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] providerRequestId: ProviderRequestId @@ -3112,6 +3320,9 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: this function's contract is detected-only. The fallback runs for its store side effect, but + // callers keep seeing null as they did before the metadata path existed. + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return null } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -3210,6 +3421,11 @@ export const createWorktreeSlice: StateCreator ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative + // rows as a side effect before returning false would silently weaken that contract. + if (!options?.requireAuthoritative) { + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) + } return false } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { @@ -3274,6 +3490,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { @@ -3325,6 +3542,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -4082,6 +4300,11 @@ export const createWorktreeSlice: StateCreator // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an + // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. + if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { + rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId]) + } const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() if (worktreeDisplayName) { diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 4c5289db8fa..38b38ace37e 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs = | LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest +export type ListKnownWorktreesForExecutionHostArgs = { + repoId: string + executionHostId: SshExecutionHostId +} + +export type HostQualifiedKnownWorktreeResult = + | { + status: 'complete' + repoId: string + executionHostId: SshExecutionHostId + result: DetectedWorktreeListResult + } + | { + status: 'rejected' + repoId: string + executionHostId: SshExecutionHostId + } + export type AuthoritativeDetectedWorktreeHost = | { kind: 'local' From 4774d38239597a2ddd027e2e900afdd14699f300 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:58:55 -0700 Subject: [PATCH 14/88] fix(sidebar): keep PR status visible with activity (#12793) --- .../sidebar/WorktreeCard.pr-display.test.tsx | 23 +-- .../src/components/sidebar/WorktreeCard.tsx | 15 +- .../sidebar/WorktreeCardReviewStatus.test.tsx | 53 ++++++ .../sidebar/WorktreeCardReviewStatus.tsx | 52 ++++++ .../sidebar/WorktreeCardStatusSlot.test.tsx | 164 +----------------- .../sidebar/WorktreeCardStatusSlot.tsx | 131 ++++---------- 6 files changed, 165 insertions(+), 273 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index a219841b949..d1e95db8c74 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -24,6 +24,7 @@ let issueCache: Record = {} let prCache: Record = {} let workspacePortScan: WorkspacePortScanResult | null = null let settings: Partial | null = null +let activityStatus = 'inactive' vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => @@ -60,7 +61,7 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'inactive' + useWorktreeActivityStatus: () => activityStatus })) vi.mock('./CacheTimer', () => ({ @@ -162,6 +163,7 @@ describe('WorktreeCard linked PR display', () => { prCache = {} workspacePortScan = null settings = null + activityStatus = 'inactive' }) it('keeps linked GH PR status out of the left status slot by default', async () => { @@ -229,11 +231,10 @@ describe('WorktreeCard linked PR display', () => { ) expect(unreadMarkup).not.toContain('aria-label="Mark as read"') - expect(unreadMarkup).toContain('PR checks: Failed · Unread') + expect(unreadMarkup).toContain('Inactive · Unread') + expect(unreadMarkup).toContain('PR checks: Failed') expect(unreadMarkup).not.toContain('Mark read') - expect(unreadMarkup).toContain('size-[13px] translate-x-px') - expect(unreadMarkup).not.toContain('lucide-bell') - expect(unreadMarkup).not.toContain('text-amber-500') + expect(unreadMarkup).toContain('size-[13px]') expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""') expect(unreadMarkup).toContain('data-worktree-unread-alert=""') expect(unreadMarkup).toContain('bg-amber-500') @@ -241,7 +242,7 @@ describe('WorktreeCard linked PR display', () => { expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80') }, 20_000) - it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => { + it('shows linked GH PR status in the title row before hosted review details are cached when new card style is on', async () => { settings = { experimentalNewWorktreeCardStyle: true } const { default: WorktreeCard } = await import('./WorktreeCard') @@ -629,9 +630,10 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('58941') }) - it('renders linked PR status in the left status slot instead of the right metadata list', async () => { + it('renders activity and linked PR status in independent sidebar lanes', async () => { settings = { experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status'] + activityStatus = 'active' hostedReviewCache = { 'local::repo-1::feature/local-branch': { data: makeHostedReview({ status: 'failure' }), @@ -645,12 +647,13 @@ describe('WorktreeCard linked PR display', () => { ) expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') + expect(markup).toContain('Active') + expect(markup).toContain('bg-emerald-500') + expect(markup).toContain('data-worktree-card-review-status=""') expect(markup).not.toContain('Linked PR #456') - expect(markup).not.toContain('CI checks') }) - it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => { + it('uses branch PR cache for the title-row status before hosted-review metadata warms', async () => { settings = { experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status'] prCache = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b64ffd91cc5..26a2d020235 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -26,6 +26,7 @@ import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus' import { cn } from '@/lib/utils' import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' @@ -1207,11 +1208,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' const hasMetadataBadge = showConflictOperationBadge const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle - // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive. + // Why: the slot owns activity and unread; review/check state has its own title-row indicator. const showCombinedStatusSlot = showStatus + const showReviewStatus = newCardStyle && showStatus && statusLaneReview !== null const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) - const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) + const showTitleRowIndicators = + (newCardStyle || compactCards) && (hasDetails || hasPorts || showReviewStatus) // Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content. const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || @@ -1370,7 +1373,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ detailsAndPortsContent ) const titleRowIndicators = showTitleRowIndicators ? ( -
{detailsAndPorts}
+
+ {showReviewStatus && statusLaneReview ? ( + + ) : null} + {detailsAndPorts} +
) : null const hasSecondaryCardContent = hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip @@ -1404,7 +1412,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ unreadTooltip={unreadTooltip} onPointerDown={stopQuickActionPointerPropagation} onToggleUnread={handleToggleUnreadQuick} - prDisplay={statusLaneReview} newCardStyle={newCardStyle} hasBranchIdentity={hasPathIdentityEnabled && Boolean(branchIdentityDisplay)} /> diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx new file mode 100644 index 00000000000..dab06044df9 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +type ReviewOverrides = { + provider?: Exclude + state?: WorktreeCardPrDisplay['state'] + status?: WorktreeCardPrDisplay['status'] +} + +function renderReview(overrides: ReviewOverrides = {}): string { + const review: WorktreeCardPrDisplay = { + provider: 'github', + number: 123, + title: 'Review me', + state: 'open', + ...overrides + } + return renderToStaticMarkup() +} + +describe('WorktreeCardReviewStatus', () => { + it.each([ + [{ state: 'merged' }, 'PR: Merged'], + [{ state: 'closed' }, 'PR: Closed'], + [{ state: 'draft' }, 'PR: Draft'], + [{ status: 'failure' }, 'PR checks: Failed'], + [{ status: 'pending' }, 'PR checks: Pending'], + [{ status: 'success' }, 'PR checks: Passing'], + [{}, 'PR: Open'] + ] as const)('describes review state %o as %s', (overrides, label) => { + expect(renderReview(overrides)).toContain(label) + }) + + it('uses the compact generic review glyph in its own lane', () => { + const markup = renderReview({ provider: 'gitlab', status: 'pending' }) + + expect(markup).toContain('data-worktree-card-review-status=""') + expect(markup).toContain('MR checks: Pending') + expect(markup).toContain('viewBox="0 0 16 16"') + expect(markup).toContain('size-[13px]') + expect(markup).toContain('text-amber-500/85') + expect(markup).not.toContain('lucide-git-merge') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx new file mode 100644 index 00000000000..cab5ba030c1 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx @@ -0,0 +1,52 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' + +type WorktreeCardReviewStatusProps = { + review: WorktreeCardPrDisplay +} + +function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { + const label = getReviewLabel(review) + if (review.state === 'merged') { + return `${label}: Merged` + } + if (review.state === 'closed') { + return `${label}: Closed` + } + if (review.state === 'draft') { + return `${label}: Draft` + } + if (review.status === 'failure') { + return `${label} checks: Failed` + } + if (review.status === 'pending') { + return `${label} checks: Pending` + } + if (review.status === 'success') { + return `${label} checks: Passing` + } + return `${label}: Open` +} + +export function WorktreeCardReviewStatus({ + review +}: WorktreeCardReviewStatusProps): React.JSX.Element { + const tooltip = getReviewStatusTooltip(review) + return ( + + + + + {tooltip} + + + + {tooltip} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..bd1971bb614 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' -import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' const mocks = vi.hoisted(() => ({ status: 'active' @@ -23,21 +22,6 @@ describe('WorktreeCardStatusSlot', () => { mocks.status = 'active' }) - const review: WorktreeCardPrDisplay = { - provider: 'github', - number: 123, - title: 'Review me', - state: 'open', - status: 'failure' - } - const gitlabReview: WorktreeCardPrDisplay = { - provider: 'gitlab', - number: 456, - title: 'Review me', - state: 'open', - status: 'pending' - } - it('lets the unread bell replace the visual status dot by default', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('bg-emerald-500') }) - it('keeps the quiet active dot ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the active dot ahead of PR status when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-branch') }) - it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('MR checks: Pending') - expect(markup).toContain('viewBox="0 0 16 16"') - expect(markup).toContain('size-[13px] translate-x-px') - expect(markup).toContain('text-amber-500/85') - expect(markup).not.toContain('lucide-git-merge') - }) - - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('shows done activity in new card style', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) expect(markup).toContain('Done') expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('uses PR status instead of the inactive dot when new card style is on', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') - expect(markup).not.toContain('bg-neutral-500/40') }) it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { @@ -363,7 +261,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('lucide-git-branch') }) - it('keeps working activity ahead of PR status in new card style', () => { + it('shows working activity in new card style', () => { mocks.status = 'working' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) @@ -382,10 +279,9 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Working') expect(markup).toContain('inline-flex size-5 items-center justify-center') expect(markup).toContain('border-yellow-500') - expect(markup).not.toContain('PR checks: Failed') }) - it('keeps permission activity ahead of PR status in new card style', () => { + it('shows permission activity in new card style', () => { mocks.status = 'permission' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) @@ -404,59 +299,6 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Needs permission') expect(markup).toContain('lucide-message-circle-question-mark') expect(markup).toContain('text-amber-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps unread ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('Mark as read') - expect(markup).not.toContain('Active · Mark as read') - expect(markup).not.toContain('PR checks: Failed') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).toContain('text-amber-500') - }) - - it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('Mark as read') - expect(markup).toContain('PR checks: Failed · Unread') - expect(markup).toContain('data-worktree-status-lane-unread=""') - expect(markup).toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('group/unread') - expect(markup).not.toContain('cursor-pointer') - expect(markup).toContain('text-rose-500/85') - expect(markup).toContain('bg-amber-500') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - expect(markup).not.toContain('bg-emerald-500') }) it('overlays an unread badge on the branch icon in new card style', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 527236c5549..d1a96bcdfea 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -3,12 +3,10 @@ import { Bell, GitBranch } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' +import { getWorktreeStatusLabel } from '@/lib/worktree-status' import { FilledBellIcon } from './WorktreeCardHelpers' import StatusIndicator from './StatusIndicator' import { useWorktreeActivityStatus } from './use-worktree-activity-status' -import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' type WorktreeCardStatusSlotProps = { worktreeId: string @@ -18,24 +16,20 @@ type WorktreeCardStatusSlotProps = { unreadTooltip: string onToggleUnread: React.MouseEventHandler onPointerDown: React.PointerEventHandler - prDisplay?: WorktreeCardPrDisplay | null newCardStyle?: boolean hasBranchIdentity?: boolean branchIdentityLabel?: string className?: string } -// Passive identity only replaces status when the workspace is inactive. -const IDENTITY_REPLACEABLE_STATUSES = new Set(['inactive']) -// Why: a missing review display can also mean provider state is unavailable, -// so the passive label names the identity cue without claiming no review exists. +// Why: the passive label names the identity cue without claiming review state. function getDefaultBranchIdentityLabel(): string { return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch') } // Why: branch-style SVGs are optically left-heavy; this keeps them aligned with // the centered activity dots in the shared status column. -const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px' -const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70` +const compactBranchStatusIconClassName = 'size-[13px] translate-x-px' +const branchStatusIconClassName = `${compactBranchStatusIconClassName} text-muted-foreground/70` // Why: a left-edge badge overlays unread on the status glyph without widening // the lane or indenting the title; ring-sidebar cuts the dot out from busy icons. const newCardUnreadAlertClassName = @@ -64,29 +58,6 @@ function overlayNewCardUnreadStatus( ) } -function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { - const label = getReviewLabel(review) - if (review.state === 'merged') { - return `${label}: Merged` - } - if (review.state === 'closed') { - return `${label}: Closed` - } - if (review.state === 'draft') { - return `${label}: Draft` - } - if (review.status === 'failure') { - return `${label} checks: Failed` - } - if (review.status === 'pending') { - return `${label} checks: Pending` - } - if (review.status === 'success') { - return `${label} checks: Passing` - } - return `${label}: Open` -} - export function WorktreeCardStatusSlot({ worktreeId, showStatus, @@ -95,7 +66,6 @@ export function WorktreeCardStatusSlot({ unreadTooltip, onToggleUnread, onPointerDown, - prDisplay = null, newCardStyle = false, hasBranchIdentity = false, branchIdentityLabel, @@ -103,70 +73,43 @@ export function WorktreeCardStatusSlot({ }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status - const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = - newCardStyle && - showStatus && - hasBranchIdentity && - prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) - const passiveStatusLabel = - canShowReviewStatus && prDisplay - ? getReviewStatusTooltip(prDisplay) - : canShowBranchStatus - ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) - : statusLabel + newCardStyle && showStatus && hasBranchIdentity && status === 'inactive' + const passiveStatusLabel = canShowBranchStatus + ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) + : statusLabel const passiveStatusTooltip = newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel // Why: working and permission already own the new-card status lane, but // unread state should still surface in tooltip/sr-only copy and reappear afterward. const showNewCardUnreadAlert = newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission' - const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName const branchStatusIcon =
) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..8911c1783c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -192,7 +192,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('PR checks: Failed') }) - it('keeps the active dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet active dot when new card style is on', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Active') + expect(markup).toContain('PR checks: Failed') expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-git-branch') + expect(markup).toContain('size-[13px] translate-x-px') + expect(markup).toContain('text-rose-500/85') + expect(markup).not.toContain('bg-emerald-500') }) it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-merge') }) - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet done dot when new card style is on', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Done') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') + expect(markup).toContain('PR checks: Failed') + expect(markup).not.toContain('bg-emerald-500') }) it('uses PR status instead of the inactive dot when new card style is on', () => { @@ -298,8 +277,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { - mocks.status = 'inactive' + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { }) it('uses context-aware branch or folder path tooltip copy', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on the branch icon in new card style', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( (['inactive']) +const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive']) // Why: a missing review display can also mean provider state is unavailable, // so the passive label names the identity cue without claiming no review exists. function getDefaultBranchIdentityLabel(): string { @@ -104,13 +103,16 @@ export function WorktreeCardStatusSlot({ const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) + newCardStyle && + showStatus && + prDisplay !== null && + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = newCardStyle && showStatus && hasBranchIdentity && prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) diff --git a/tests/e2e/worktree-card-status-indicator.spec.ts b/tests/e2e/worktree-card-status-indicator.spec.ts deleted file mode 100644 index 78075cb26c2..00000000000 --- a/tests/e2e/worktree-card-status-indicator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Regression coverage for active status taking precedence over passive identity (#8813). - -import type { Locator, Page } from '@stablyai/playwright-test' -import { test, expect } from './helpers/orca-app' -import { - waitForSessionReady, - waitForActiveWorktree, - getAllWorktreeIds, - ensureTerminalVisible -} from './helpers/store' -import { worktreeRow, worktreeRowSurface } from './worktree-row-locators' - -function statusLane(page: Page, worktreeId: string): Locator { - return worktreeRow(page, worktreeId).locator('[data-worktree-card-status-slot]').first() -} - -function statusDot(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('span.bg-emerald-500').first() -} - -function branchIdentityGlyph(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('svg.lucide-git-branch') -} - -// PTY liveness, not the tab row, drives the activity heuristic. -async function waitForLivePty(page: Page, worktreeId: string): Promise { - await expect - .poll( - async () => - page.evaluate((id) => { - const state = window.__store!.getState() - return (state.tabsByWorktree[id] ?? []).some( - (tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0 - ) - }, worktreeId), - { timeout: 30_000, message: `No live PTY attached for worktree ${worktreeId}` } - ) - .toBe(true) -} - -test.describe('Worktree card status indicator', () => { - test.beforeEach(async ({ orcaPage }) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - }) - - test('paints the emerald Active dot instead of the grey branch glyph once a workspace goes live', async ({ - orcaPage - }) => { - const liveWorktreeId = await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, liveWorktreeId) - - // The regression requires both the new card style and branch identity. - await orcaPage.evaluate(async () => { - const state = window.__store!.getState() - await state.updateSettings({ experimentalNewWorktreeCardStyle: true }) - state.setWorktreeCardProperties(['status', 'unread', 'branch']) - }) - - const quietWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== liveWorktreeId) - if (!quietWorktreeId) { - throw new Error('Seeded repo did not expose a second worktree to keep quiet') - } - - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Branch') - - await expect(statusDot(orcaPage, liveWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, liveWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, liveWorktreeId)).toHaveCount(0) - - await worktreeRowSurface(orcaPage, quietWorktreeId).click() - await expect - .poll(async () => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId)) - .toBe(quietWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, quietWorktreeId) - - await expect(statusDot(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toHaveCount(0) - }) -}) From ff01fad4adda08423f657d10ecacaf682e0a1601 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:28:10 -0700 Subject: [PATCH 23/88] fix(runtime): stop a stalled tick from forgiving banked missed probes (#12841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review of #12790 demonstrated a real leak: the resume-from-pause pardon cleared banked misses outright, so a host whose sweep stalls once every three ticks reset the budget forever and a dead socket was never reaped. The reviewer ran 300 sweeps against a permanently dead peer with a >1.5x gap every third tick and observed zero terminate() calls. Pre-#12790 that required a stall on *every* tick; the counter widened the pathological window 3x, and the failure mode is permanent non-reaping — the MAX_WS_CONNECTIONS leak the reaper exists to prevent. A stalled tick now charges no miss, which is all the original rationale needed (the client had no chance to answer that probe), but no longer forgives the misses already banked. A live client still clears its own count by answering the probe that is still sent on the stalled tick. The tolerance test's pause case is rewritten to assert the new contract rather than the old forgive-everything one, and a new test pins the leak directly: a host stalling every third tick must still reap a dead socket. Co-authored-by: Jinwoo-H --- ...r-heartbeat-missed-probe-tolerance.test.ts | 44 +++++++++++++++---- .../rpc/remote-runtime-server-heartbeat.ts | 15 +++---- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts index 2e3a3427175..665d94b0f41 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -53,7 +53,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { for (let probe = 0; probe < 6; probe += 1) { now += INTERVAL_MS await vi.advanceTimersByTimeAsync(INTERVAL_MS) - missesBeforeReap.push((socket.terminate as unknown as ReturnType).mock.calls.length) + missesBeforeReap.push( + (socket.terminate as unknown as ReturnType).mock.calls.length + ) } // Silence must be tolerated for more than a single probe, and must eventually end the socket. @@ -85,7 +87,7 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { heartbeat.stop() }) - it('clears accumulated misses when the server event loop resumes from a pause', async () => { + it('charges no miss for a paused tick but keeps the ones already banked', async () => { vi.useFakeTimers() let now = 1_000 const socket = makeSocket() @@ -114,8 +116,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { expect(toleratedMisses).toBeGreaterThan(1) heartbeat.stop() - // Replay on a fresh socket: bank misses to one short of the limit, suspend, then bank the same - // number again. The pause must write the earlier misses off, not top them up to the limit. + // Replay on a fresh socket: bank misses to one short of the limit, then suspend. The stalled tick + // must charge nothing — the client had no chance to answer it — but it must not forgive the misses + // already banked, or a host that stalls periodically could shelter a dead socket indefinitely. const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) const resumedSocket = makeSocket() resumed.noteAlive(resumedSocket) @@ -129,11 +132,36 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { now += 3_600_000 await vi.advanceTimersByTimeAsync(INTERVAL_MS) - for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { - await sweep() - } - + // The suspend itself costs the socket nothing. expect(reaped(resumedSocket)).toBe(false) + + // But the pre-suspend evidence survives, so the very next unanswered probe completes the budget. + await sweep() + expect(reaped(resumedSocket)).toBe(true) resumed.stop() }) + + // Why: pre-fix the pardon cleared banked misses, so a host stalling once every few ticks reset the + // budget forever and a dead socket was never reaped — the connection leak the reaper exists to stop. + it('still reaps a dead socket when the host stalls repeatedly', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const reaped = (): boolean => + (socket.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Stall every third tick — the worst case the old pardon could not survive. + for (let tick = 0; tick < 30 && !reaped(); tick += 1) { + now += tick % 3 === 2 ? INTERVAL_MS * 2 : INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reaped()).toBe(true) + heartbeat.stop() + }) }) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 3913e9d9b6b..6134259c51f 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -52,21 +52,20 @@ export class RemoteRuntimeServerHeartbeat { const tickAt = this.now() const elapsedMs = tickAt - (this.lastTickAt ?? tickAt) this.lastTickAt = tickAt - const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 + // Why: a delayed tick cannot infer that clients missed a probe they had no chance to answer, so + // this sweep must not charge one. It must not *forgive* either: clearing banked misses lets a host + // that stalls once every few ticks protect a dead socket forever, which is the connection leak the + // reaper exists to prevent. Skipping the charge is enough — a live client clears its own count by + // answering the probe still sent below. + const stalledTick = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 let reaped = 0 let clientCount = 0 for (const socket of clients) { clientCount += 1 - if (resumedFromPause) { - // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to - // answer. Seeding alive also clears any misses banked before the pause, so a suspend can never - // top up a budget the client was never given a chance to defend. - this.alive.add(socket) - } if (this.alive.has(socket)) { this.alive.delete(socket) this.missedProbes.delete(socket) - } else { + } else if (!stalledTick) { const missed = (this.missedProbes.get(socket) ?? 0) + 1 if (missed >= this.missedProbeLimit) { this.missedProbes.delete(socket) From 81e7a2ddf776a35a420af41ec8ae55b25804c033 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:52:22 -0700 Subject: [PATCH 24/88] fix(terminal): recover live panes after renderer restart (STA-3536) (#12776) * fix(terminal): stop transient probe blips from erroring restored panes (STA-3536) terminal_pane_owner_unverified fired for every restored pane whenever one liveness probe answer went missing: a cold-start daemon draining an attach stampede misses the 2s getSize deadline, and a wedged superseded daemon (protocol upgrades leave them running) turns every unmapped fan-out probe null forever. - probePtyOwners now skips legacy daemons whose startup inventory listing succeeded: fresh sessions never route to them, so they provably don't own an unmapped id and one wedged zombie can't poison every pane's verdict. - attachStablePaneOwner retries the probe over a short backoff ladder before surfacing unverified, so a single missed deadline resolves to a verdict. - The renderer replaces the raw error code with actionable copy. * fix(terminal): stop retrying definitive owner probes * fix(terminal): recover live panes after renderer restart * refactor(terminal): share owner resolution abort guard --- src/main/daemon/client.ts | 8 +- src/main/daemon/daemon-errors.test.ts | 20 + src/main/daemon/daemon-errors.ts | 14 + .../daemon-pty-adapter-subscription-fanout.ts | 8 +- src/main/daemon/daemon-pty-liveness-probe.ts | 20 - src/main/daemon/daemon-pty-router.test.ts | 119 ++++- src/main/daemon/daemon-pty-router.ts | 48 +- .../daemon-session-owner-resolution.test.ts | 450 ++++++++++++++++++ .../daemon/daemon-session-owner-resolution.ts | 300 ++++++++++++ .../daemon/degraded-daemon-owner-recovery.ts | 82 ++++ .../degraded-daemon-pty-provider.test.ts | 49 +- .../daemon/degraded-daemon-pty-provider.ts | 46 +- .../daemon/degraded-daemon-session-routing.ts | 15 - src/main/ipc/pty.test.ts | 206 ++++++-- src/main/ipc/pty.ts | 128 ++++- src/main/persistence.test.ts | 215 +++++++++ src/main/persistence.ts | 34 +- src/main/providers/local-pty-provider.ts | 3 +- src/main/providers/pty-provider-contract.ts | 4 + .../terminal-pane/TerminalErrorToast.test.ts | 19 + .../terminal-pane/TerminalErrorToast.tsx | 19 +- src/renderer/src/i18n/locales/en.json | 3 +- 22 files changed, 1651 insertions(+), 159 deletions(-) create mode 100644 src/main/daemon/daemon-errors.test.ts delete mode 100644 src/main/daemon/daemon-pty-liveness-probe.ts create mode 100644 src/main/daemon/daemon-session-owner-resolution.test.ts create mode 100644 src/main/daemon/daemon-session-owner-resolution.ts create mode 100644 src/main/daemon/degraded-daemon-owner-recovery.ts diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index b6229521c8b..dd1d9066a02 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -18,6 +18,7 @@ import type { DaemonEvent } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' +import { decodeDaemonResponseError } from './daemon-errors' const CONNECT_TIMEOUT_MS = 5000 const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 @@ -429,7 +430,12 @@ export class DaemonClient { if (response.ok) { pending.resolve(response.payload) } else { - pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error))) + const decoded = decodeDaemonResponseError(response.error) + pending.reject( + decoded instanceof DaemonProtocolError + ? new DaemonProtocolError(addNodePtyRecoveryHint(response.error)) + : decoded + ) } } } diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts new file mode 100644 index 00000000000..d12ce645ee7 --- /dev/null +++ b/src/main/daemon/daemon-errors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + DaemonProtocolError, + decodeDaemonResponseError, + SessionNotFoundError +} from './daemon-errors' + +describe('decodeDaemonResponseError', () => { + it('types the exact legacy session-absence response', () => { + expect(decodeDaemonResponseError('Session not found: pty-1')).toBeInstanceOf( + SessionNotFoundError + ) + }) + + it('keeps unrelated daemon failures non-authoritative', () => { + expect(decodeDaemonResponseError('proxy failed: Session not found: pty-1')).toBeInstanceOf( + DaemonProtocolError + ) + }) +}) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index 8077add319a..bddb3690a4a 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -20,3 +20,17 @@ export class SessionNotFoundError extends Error { this.name = 'SessionNotFoundError' } } + +export class TerminalSessionOwnerUnverifiedError extends Error { + constructor(sessionId: string) { + super(`Terminal session owner could not be verified: ${sessionId}`) + this.name = 'TerminalSessionOwnerUnverifiedError' + } +} + +export function decodeDaemonResponseError(message: string): Error { + const prefix = 'Session not found: ' + return message.startsWith(prefix) + ? new SessionNotFoundError(message.slice(prefix.length)) + : new DaemonProtocolError(message) +} diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts index e4855640ada..1d82ef70b7b 100644 --- a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -10,7 +10,8 @@ export class DaemonPtyAdapterSubscriptionFanout { constructor( private readonly adapters: readonly DaemonPtyAdapter[], - onAdapterExit: (id: string) => void + onAdapterExit: (id: string) => void, + onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void ) { for (const adapter of adapters) { this.unsubscribers.push( @@ -24,7 +25,10 @@ export class DaemonPtyAdapterSubscriptionFanout { for (const listener of this.exitListeners) { listener(payload) } - }) + }), + ...(onAdapterIdentityChanged && typeof adapter.onDaemonIdentityChanged === 'function' + ? [adapter.onDaemonIdentityChanged(() => onAdapterIdentityChanged(adapter))] + : []) ) } } diff --git a/src/main/daemon/daemon-pty-liveness-probe.ts b/src/main/daemon/daemon-pty-liveness-probe.ts deleted file mode 100644 index ba2c448bbd2..00000000000 --- a/src/main/daemon/daemon-pty-liveness-probe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IPtyProvider } from '../providers/types' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' - -export async function probePtyOwners( - id: string, - routed: IPtyProvider | undefined, - possibleOwners: readonly DaemonPtyAdapter[] -): Promise { - if (routed) { - return routed.probePtyLiveness - ? await routed.probePtyLiveness(id) - : (routed.hasPty?.(id) ?? null) - } - const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id))) - return results.some((result) => result === true) - ? true - : results.every((result) => result === false) - ? false - : null -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 2bee5164ce9..1930224c2d5 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { @@ -18,6 +19,7 @@ type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void emitBackground: (event: PtyBackgroundStreamEvent) => void emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void triggerWriteUnavailable: (id: string) => void } @@ -44,6 +46,7 @@ function createAdapter( const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = [] + const identityChangeListeners: (() => void)[] = [] return { protocolVersion, supportsGitCredentialGuardHost: () => @@ -136,6 +139,15 @@ function createAdapter( } } ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), ackColdRestore: vi.fn(), clearTombstone: vi.fn(), reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), @@ -156,6 +168,7 @@ function createAdapter( listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) } }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), triggerWriteUnavailable: (id: string) => { for (const listener of writeUnavailableListeners) { listener({ id }) @@ -498,14 +511,16 @@ describe('DaemonPtyRouter', () => { expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session') }) - it('probes every possible daemon owner for an unmapped session', async () => { + it('discovers an unmapped live session from one coalesced inventory', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['surviving-session']) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await expect(router.probePtyLiveness('surviving-session')).resolves.toBe(true) - expect(current.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session') - expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session') + expect(current.listProcesses).toHaveBeenCalledOnce() + expect(legacy.listProcesses).toHaveBeenCalledOnce() + expect(current.probePtyLiveness).not.toHaveBeenCalled() + expect(legacy.probePtyLiveness).not.toHaveBeenCalled() }) it('does not report absence while any possible daemon owner is unavailable', async () => { @@ -517,6 +532,102 @@ describe('DaemonPtyRouter', () => { await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() }) + it('routes an attach to a legacy session created after startup inventory', async () => { + const current = createAdapter('current') + const legacySessions = ['legacy-at-startup'] + const legacy = createAdapter('legacy', legacySessions) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + legacySessions.push('legacy-created-later') + + await router.spawn({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + + expect(legacy.spawn).toHaveBeenCalledExactlyOnceWith({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + expect(current.spawn).not.toHaveBeenCalled() + }) + + it('still routes probes for a discovered session to its owning legacy daemon', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await expect(router.probePtyLiveness('legacy-session')).resolves.toBe(true) + expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('legacy-session') + }) + + it('keeps consulting a legacy daemon whose inventory listing failed', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + // Without an inventory nothing proves the legacy daemon doesn't own this id. + await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.probePtyLiveness).not.toHaveBeenCalled() + }) + + it('keeps an attach unresolved when any possible owner inventory fails', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + + await expect( + router.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + + it('re-resolves a stale positive route before declaring an owner absent', async () => { + const current = createAdapter('current') + const firstSessions = ['moved-session'] + const secondSessions: string[] = [] + const first = createAdapter('first', firstSessions) + const second = createAdapter('second', secondSessions) + const router = new DaemonPtyRouter({ current, legacy: [first, second] }) + await router.discoverLegacySessions() + firstSessions.length = 0 + secondSessions.push('moved-session') + vi.mocked(first.spawn).mockRejectedValueOnce(new SessionNotFoundError('moved-session')) + + await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 }) + + expect(first.spawn).toHaveBeenCalledOnce() + expect(second.spawn).toHaveBeenCalledOnce() + }) + + it('invalidates positive routes when a daemon endpoint identity changes', async () => { + const currentSessions: string[] = [] + const legacySessions = ['moved-session'] + const current = createAdapter('current', currentSessions) + const legacy = createAdapter('legacy', legacySessions) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + legacySessions.length = 0 + currentSessions.push('moved-session') + + legacy.emitIdentityChange() + await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 }) + + expect(current.spawn).toHaveBeenCalledOnce() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => { const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) const legacy = createAdapter( @@ -582,7 +693,7 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy exited') await expect(router.listProcesses()).rejects.toThrow('legacy exited') expect(router.getLegacyAdapters()).toEqual([legacy]) - expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(3) }) it('pins colliding unmapped legacy ids falling through to the current daemon', async () => { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0f07da4dc18..8429495ed9f 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -9,44 +9,44 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { probePtyOwners } from './daemon-pty-liveness-probe' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() + private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current this.legacy = opts.legacy - this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => { - this.sessionAdapters.delete(id) - }) + this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters) + this.subscriptions = new DaemonPtyAdapterSubscriptionFanout( + this.allAdapters(), + (id) => { + this.ownerResolver.forgetRoute(id) + }, + (adapter) => this.ownerResolver.invalidateProvider(adapter) + ) } async discoverLegacySessions(): Promise { - for (const adapter of this.legacy) { - try { - const sessions = await adapter.listProcesses() - for (const session of sessions) { - this.sessionAdapters.set(session.id, adapter) - } - } catch (error) { - console.warn('[daemon] Failed to discover legacy daemon sessions', error) - } - } + await this.ownerResolver.discoverRoutes() } async spawn(opts: PtySpawnOptions): Promise { + if (opts.attachOnly && opts.sessionId) { + return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) + } const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined const target = adapter ?? this.current const result = await target.spawn(opts) // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. if (!result.exitedBeforeSpawnReply) { - this.sessionAdapters.set(result.id, target) + this.ownerResolver.recordRoute(result.id, target, result.incarnationId) } return result } @@ -86,7 +86,7 @@ export class DaemonPtyRouter implements IPtyProvider { } async probePtyLiveness(id: string): Promise { - return await probePtyOwners(id, this.sessionAdapters.get(id), this.allAdapters()) + return await this.ownerResolver.probe(id) } write(id: string, data: string): void { @@ -121,7 +121,7 @@ export class DaemonPtyRouter implements IPtyProvider { adapter.ackColdRestore(id) } if (this.sessionAdapters.get(id) === adapter) { - this.sessionAdapters.delete(id) + this.ownerResolver.forgetRoute(id, adapter) } } } @@ -240,6 +240,7 @@ export class DaemonPtyRouter implements IPtyProvider { }> { const alive: string[] = [] const killed: string[] = [] + const aliveProviders = new Map>() for (const adapter of this.allAdapters()) { const result = await adapter.reconcileOnStartup(validWorktreeIds) // Why: daemon startup can reconcile many restored sessions; spreading @@ -251,10 +252,17 @@ export class DaemonPtyRouter implements IPtyProvider { killed.push(id) } for (const id of result.alive) { - this.sessionAdapters.set(id, adapter) + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) } - for (const id of result.killed) { - this.sessionAdapters.delete(id) + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.ownerResolver.recordRoute(id, providers.values().next().value!) + } else { + this.ownerResolver.forgetRoute(id) } } return { alive, killed } diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts new file mode 100644 index 00000000000..277c0750a84 --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' + +function provider( + processes: () => Promise, + spawn: (opts: PtySpawnOptions) => Promise = async (opts) => ({ + id: opts.sessionId ?? 'new' + }) +): IPtyProvider { + return { + listProcesses: vi.fn(processes), + spawn: vi.fn(spawn) + } as unknown as IPtyProvider +} + +describe('DaemonSessionOwnerResolver', () => { + it('coalesces concurrent all-provider inventories', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const firstInventory = vi.fn(async () => { + await gate + return [] + }) + const secondInventory = vi.fn(async () => { + await gate + return [] + }) + const first = provider(firstInventory) + const second = provider(secondInventory) + const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + + const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')]) + await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce()) + expect(secondInventory).toHaveBeenCalledOnce() + release() + + await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]) + expect(firstInventory).toHaveBeenCalledOnce() + expect(secondInventory).toHaveBeenCalledOnce() + }) + + it('bounds restore inventory requests across many panes and daemon generations', async () => { + const inventories = Array.from({ length: 51 }, (_, index) => + vi.fn(async () => { + if (index === 50) { + throw new Error('offline') + } + return [] + }) + ) + const resolver = new DaemonSessionOwnerResolver( + inventories.map((inventory) => provider(inventory)), + new Map() + ) + + for (let index = 0; index < 40; index += 1) { + await resolver.resolve(`pane-${index}`) + } + + for (const inventory of inventories) { + expect(inventory).toHaveBeenCalledOnce() + } + }) + + it('reuses incomplete inventory across serialized restores', async () => { + const firstInventory = vi.fn(async () => []) + const secondInventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [provider(firstInventory), provider(secondInventory)], + new Map() + ) + + await resolver.resolve('first') + await resolver.resolve('second') + + expect(firstInventory).toHaveBeenCalledOnce() + expect(secondInventory).toHaveBeenCalledOnce() + }) + + it('does not extend incomplete inventory lifetime on cache hits', async () => { + let now = 1_000 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now) + const inventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [provider(async () => []), provider(inventory)], + new Map() + ) + + try { + await resolver.resolve('first') + now += 900 + await resolver.resolve('second') + now += 101 + await resolver.resolve('third') + + expect(inventory).toHaveBeenCalledTimes(2) + } finally { + clock.mockRestore() + } + }) + + it('does not let startup inventory hide a session created before restore', async () => { + const sessions: PtyProcessInfo[] = [] + const inventory = vi.fn(async () => sessions) + const owner = provider(inventory, async (opts) => ({ + id: opts.sessionId!, + incarnationId: 'live', + isReattach: true + })) + const resolver = new DaemonSessionOwnerResolver([owner, provider(async () => [])], new Map()) + + await resolver.discoverRoutes() + sessions.push({ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true }) + expect(inventory).toHaveBeenCalledTimes(2) + }) + + it('uses the expected incarnation to disambiguate duplicate session ids', async () => { + const oldOwner = provider(async () => [ + { id: 'session', incarnationId: 'old', cwd: '', title: 'old' } + ]) + const exactOwner = provider(async () => [ + { id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' } + ]) + const resolver = new DaemonSessionOwnerResolver([oldOwner, exactOwner], new Map()) + + await expect(resolver.resolve('session', 'expected')).resolves.toMatchObject({ + kind: 'owner', + provider: exactOwner + }) + }) + + it('accepts one exact-id owner when persisted incarnation evidence is stale', async () => { + const liveOwner = provider(async () => [ + { id: 'session', incarnationId: 'live', cwd: '', title: 'live' } + ]) + const resolver = new DaemonSessionOwnerResolver([liveOwner], new Map()) + + await expect(resolver.resolve('session', 'stale')).resolves.toMatchObject({ + kind: 'owner', + provider: liveOwner + }) + }) + + it('does not inventory unrelated providers for a routed stale persisted incarnation', async () => { + const routedOwner = provider( + async () => [{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }], + async () => ({ id: 'session', incarnationId: 'live', isReattach: true }) + ) + const unrelatedInventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [routedOwner, provider(unrelatedInventory)], + new Map([['session', routedOwner]]) + ) + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'stale', + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true }) + expect(unrelatedInventory).not.toHaveBeenCalled() + }) + + it('re-resolves a routed incarnation mismatch against every possible owner', async () => { + const staleRoute = provider( + async () => [{ id: 'session', incarnationId: 'other', cwd: '', title: 'stale' }], + async () => ({ id: 'session', incarnationId: 'other', isReattach: true }) + ) + const exactOwner = provider( + async () => [{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }], + async () => ({ id: 'session', incarnationId: 'expected', isReattach: true }) + ) + const resolver = new DaemonSessionOwnerResolver( + [staleRoute, exactOwner], + new Map([['session', staleRoute]]) + ) + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'expected', + expectedIncarnationIsAuthoritative: true, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ incarnationId: 'expected' }) + expect(staleRoute.spawn).not.toHaveBeenCalled() + }) + + it('refreshes incomplete inventory when a routed owner refuses a moved session', async () => { + let firstOwnsSession = true + let secondOwnsSession = false + const first = provider( + async () => + firstOwnsSession + ? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'first' }] + : [], + async () => { + throw new SessionNotFoundError('session') + } + ) + const second = provider( + async () => + secondOwnsSession + ? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'second' }] + : [], + async () => ({ id: 'session', incarnationId: 'runtime', isReattach: true }) + ) + const unavailable = provider(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver([first, second, unavailable], new Map()) + + await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({ + kind: 'owner', + provider: first + }) + firstOwnsSession = false + secondOwnsSession = true + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'runtime', + expectedIncarnationIsAuthoritative: true, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'runtime', isReattach: true }) + expect(first.spawn).toHaveBeenCalledOnce() + expect(second.spawn).toHaveBeenCalledOnce() + expect(second.listProcesses).toHaveBeenCalledTimes(2) + }) + + it('accepts exact runtime proof from an incomplete inventory', async () => { + const exactOwner = provider(async () => [ + { id: 'session', incarnationId: 'runtime', cwd: '', title: 'exact' } + ]) + const resolver = new DaemonSessionOwnerResolver( + [ + exactOwner, + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({ + kind: 'owner', + provider: exactOwner + }) + }) + + it('does not trust persisted incarnation proof from an incomplete inventory', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => [ + { id: 'session', incarnationId: 'persisted', cwd: '', title: 'candidate' } + ]), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.resolve('session', 'persisted')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('accepts positive liveness proof from an incomplete inventory', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => [{ id: 'session', cwd: '', title: 'live' }]), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.probe('session')).resolves.toBe(true) + }) + + it('fails closed when duplicate owners cannot be disambiguated', async () => { + const first = provider(async () => [{ id: 'session', cwd: '', title: 'first' }]) + const second = provider(async () => [{ id: 'session', cwd: '', title: 'second' }]) + const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + + await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('reports confirmed absence from a sole owner', async () => { + const owner = provider( + async () => [], + async () => { + throw new SessionNotFoundError('missing') + } + ) + const resolver = new DaemonSessionOwnerResolver([owner], new Map()) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(owner.spawn).toHaveBeenCalledOnce() + expect(owner.listProcesses).not.toHaveBeenCalled() + }) + + it('keeps absence unverified when there are no possible owners', async () => { + const resolver = new DaemonSessionOwnerResolver([], new Map()) + + await expect(resolver.resolve('missing')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('preserves an unresolved owner when any provider inventory fails', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => []), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => { + const candidate = provider( + async () => [{ id: 'session', cwd: '', title: 'candidate' }], + async () => { + throw new SessionNotFoundError('session') + } + ) + const resolver = new DaemonSessionOwnerResolver( + [ + candidate, + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('does not turn a post-inventory owner refusal into aggregate absence', async () => { + const candidate = provider( + async () => [{ id: 'session', cwd: '', title: 'candidate' }], + async () => { + throw new SessionNotFoundError('session') + } + ) + const resolver = new DaemonSessionOwnerResolver( + [candidate, provider(async () => [])], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('pre-routes every uniquely inventoried session for serialized restores', async () => { + const inventory = vi.fn(async () => [ + { id: 'first', cwd: '', title: 'first' }, + { id: 'second', cwd: '', title: 'second' } + ]) + const owner = provider(inventory) + const emptyInventory = vi.fn(async () => []) + const resolver = new DaemonSessionOwnerResolver([owner, provider(emptyInventory)], new Map()) + + await resolver.spawnAttachOnly({ sessionId: 'first', attachOnly: true, cols: 80, rows: 24 }) + await resolver.spawnAttachOnly({ sessionId: 'second', attachOnly: true, cols: 80, rows: 24 }) + + expect(inventory).toHaveBeenCalledOnce() + expect(emptyInventory).toHaveBeenCalledOnce() + }) + + it('discards an inventory completed after daemon identity replacement', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const inventory = vi.fn(async () => { + await gate + return [{ id: 'session', cwd: '', title: 'stale' }] + }) + const owner = provider(inventory) + const resolver = new DaemonSessionOwnerResolver([owner], new Map()) + + const resolution = resolver.resolve('session') + await vi.waitFor(() => expect(inventory).toHaveBeenCalledOnce()) + resolver.invalidateProvider(owner) + release() + + await expect(resolution).resolves.toEqual({ kind: 'unknown' }) + await resolver.resolve('session') + expect(inventory).toHaveBeenCalledTimes(2) + }) + + it('restores a proven route when identity changes during exact reattach', async () => { + const routes = new Map() + let resolver!: DaemonSessionOwnerResolver + let owner!: IPtyProvider + owner = provider( + async () => [], + async () => { + resolver.invalidateProvider(owner) + return { id: 'session', incarnationId: 'inc', isReattach: true } + } + ) + routes.set('session', owner) + resolver = new DaemonSessionOwnerResolver([owner], routes) + + await resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + + expect(routes.get('session')).toBe(owner) + }) +}) diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts new file mode 100644 index 00000000000..d35d2795d56 --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -0,0 +1,300 @@ +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' + +export type DaemonSessionOwnerResolution = + | { kind: 'owner'; provider: T } + | { kind: 'absent' } + | { kind: 'unknown' } + +type ProviderInventory = { provider: T; processes: PtyProcessInfo[] | null } + +type OwnerInventory = { + candidatesBySessionId: Map + complete: boolean + epoch: number +} + +const OWNER_RESOLUTION_TIMEOUT_MS = 2_000 +const OWNER_INVENTORY_CACHE_MS = 1_000 +const FAILED_PROVIDER_COOLDOWN_MS = 1_000 + +function assertClientConnected(signal: PtySpawnOptions['signal']): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } +} + +export class DaemonSessionOwnerResolver { + private inventoryInFlight: Promise> | null = null + private cachedInventory: { value: OwnerInventory; expiresAt: number } | null = null + private readonly failedProviderCooldowns = new Map() + private readonly routeIncarnations = new Map() + private epoch = 0 + + constructor( + private readonly providers: readonly T[], + private readonly routes: Map + ) {} + + invalidateProvider(provider: T): void { + this.epoch += 1 + this.inventoryInFlight = null + this.cachedInventory = null + this.failedProviderCooldowns.clear() + for (const [sessionId, routed] of this.routes) { + if (routed === provider) { + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + } + } + } + + async spawnAttachOnly(opts: PtySpawnOptions & { sessionId: string }): Promise { + assertClientConnected(opts.signal) + const routed = this.providers.find((provider) => provider === this.routes.get(opts.sessionId)) + const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined) + const routedIncarnation = this.routeIncarnations.get(opts.sessionId) + const routeNeedsAuthoritativeResolution = + direct && + routed && + opts.expectedIncarnationIsAuthoritative === true && + routedIncarnation !== opts.expectedIncarnationId + if (direct && !routeNeedsAuthoritativeResolution) { + try { + const result = await direct.spawn(opts) + if ( + !result.exitedBeforeSpawnReply && + result.id === opts.sessionId && + result.isReattach === true + ) { + this.recordRoute(result.id, direct, result.incarnationId) + } + return result + } catch (error) { + if (!(error instanceof SessionNotFoundError)) { + throw error + } + if (this.providers.length === 1) { + throw error + } + if (routed && this.routes.get(opts.sessionId) === routed) { + this.forgetRoute(opts.sessionId, routed) + } + } + } + + assertClientConnected(opts.signal) + const resolution = await this.resolve( + opts.sessionId, + opts.expectedIncarnationId, + opts.expectedIncarnationIsAuthoritative + ) + assertClientConnected(opts.signal) + if (resolution.kind === 'unknown') { + throw new TerminalSessionOwnerUnverifiedError(opts.sessionId) + } + if (resolution.kind === 'absent') { + throw new SessionNotFoundError(opts.sessionId) + } + try { + const result = await resolution.provider.spawn(opts) + if ( + !result.exitedBeforeSpawnReply && + result.id === opts.sessionId && + result.isReattach === true + ) { + this.recordRoute(result.id, resolution.provider, result.incarnationId) + } + return result + } catch (error) { + if (error instanceof SessionNotFoundError && this.providers.length > 1) { + throw new TerminalSessionOwnerUnverifiedError(opts.sessionId) + } + throw error + } + } + + async probe(sessionId: string): Promise { + const routed = this.providers.find((provider) => provider === this.routes.get(sessionId)) + const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined) + if (direct) { + const verdict = direct.probePtyLiveness + ? await direct.probePtyLiveness(sessionId) + : (direct.hasPty?.(sessionId) ?? null) + if (verdict !== false || this.providers.length === 1) { + return verdict + } + if (this.routes.get(sessionId) === routed) { + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + } + } + const inventory = await this.inventory(false) + if (inventory.epoch !== this.epoch) { + return null + } + if ((inventory.candidatesBySessionId.get(sessionId)?.length ?? 0) > 0) { + return true + } + const resolution = this.resolveInventory(inventory, sessionId) + if (resolution.kind === 'owner') { + return resolution.provider === routed ? null : true + } + return resolution.kind === 'absent' ? false : null + } + + async resolve( + sessionId: string, + expectedIncarnationId?: string, + expectedIncarnationIsAuthoritative = false + ): Promise> { + const inventory = await this.inventory() + if (inventory.epoch !== this.epoch) { + return { kind: 'unknown' } + } + const resolution = this.resolveInventory( + inventory, + sessionId, + expectedIncarnationId, + expectedIncarnationIsAuthoritative + ) + if ( + (!inventory.complete || resolution.kind === 'unknown') && + this.cachedInventory?.value !== inventory + ) { + this.cachedInventory = { + value: inventory, + expiresAt: Date.now() + OWNER_INVENTORY_CACHE_MS + } + } + return resolution + } + + async discoverRoutes(): Promise { + await this.inventory() + this.failedProviderCooldowns.clear() + this.cachedInventory = null + } + + recordRoute(sessionId: string, provider: T, incarnationId?: string): void { + this.routes.set(sessionId, provider) + this.routeIncarnations.set(sessionId, incarnationId) + } + + forgetRoute(sessionId: string, provider?: T): void { + if (provider && this.routes.get(sessionId) !== provider) { + return + } + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + this.cachedInventory = null + } + + private resolveInventory( + inventory: OwnerInventory, + sessionId: string, + expectedIncarnationId?: string, + expectedIncarnationIsAuthoritative = false + ): DaemonSessionOwnerResolution { + const candidates = inventory.candidatesBySessionId.get(sessionId) ?? [] + const providers = new Set(candidates.map(({ provider }) => provider)) + const exactProviders = new Set( + candidates + .filter(({ process }) => process.incarnationId === expectedIncarnationId) + .map(({ provider }) => provider) + ) + const exactProvider = + expectedIncarnationId && exactProviders.size === 1 + ? exactProviders.values().next().value + : undefined + const soleProvider = providers.size === 1 ? providers.values().next().value : undefined + const provider = + (exactProvider && (inventory.complete || expectedIncarnationIsAuthoritative) + ? exactProvider + : undefined) ?? + (!expectedIncarnationIsAuthoritative && inventory.complete ? soleProvider : undefined) + if (provider) { + const process = candidates.find((candidate) => candidate.provider === provider)?.process + this.recordRoute(sessionId, provider, process?.incarnationId) + return { kind: 'owner', provider } + } + if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) { + return { kind: 'unknown' } + } + return { kind: 'absent' } + } + + private inventory(allowCached = true): Promise> { + if (this.inventoryInFlight) { + return this.inventoryInFlight + } + if ( + allowCached && + this.cachedInventory?.value.epoch === this.epoch && + this.cachedInventory.expiresAt > Date.now() + ) { + return Promise.resolve(this.cachedInventory.value) + } + this.cachedInventory = null + const deadlineMs = Date.now() + OWNER_RESOLUTION_TIMEOUT_MS + const epoch = this.epoch + const inventory = Promise.all( + this.providers.map(async (provider): Promise> => { + if ((this.failedProviderCooldowns.get(provider) ?? 0) > Date.now()) { + return { provider, processes: null } + } + this.failedProviderCooldowns.delete(provider) + try { + return { provider, processes: await provider.listProcesses({ deadlineMs }) } + } catch { + if (epoch === this.epoch) { + this.failedProviderCooldowns.set(provider, Date.now() + FAILED_PROVIDER_COOLDOWN_MS) + } + return { provider, processes: null } + } + }) + ) + .then((entries) => this.indexInventory(entries, epoch)) + .finally(() => { + if (this.inventoryInFlight === inventory) { + this.inventoryInFlight = null + } + }) + this.inventoryInFlight = inventory + return inventory + } + + private indexInventory(entries: ProviderInventory[], epoch: number): OwnerInventory { + const candidatesBySessionId = new Map() + let complete = true + for (const entry of entries) { + if (!entry.processes) { + complete = false + continue + } + for (const process of entry.processes) { + const candidates = candidatesBySessionId.get(process.id) ?? [] + candidates.push({ provider: entry.provider, process }) + candidatesBySessionId.set(process.id, candidates) + } + } + if (complete && epoch === this.epoch) { + for (const [sessionId, candidates] of candidatesBySessionId) { + const providers = new Set(candidates.map(({ provider }) => provider)) + if (providers.size === 1) { + const provider = providers.values().next().value! + const process = candidates.find((candidate) => candidate.provider === provider)?.process + this.recordRoute(sessionId, provider, process?.incarnationId) + } else { + this.forgetRoute(sessionId) + } + } + } + return { candidatesBySessionId, complete, epoch } + } +} diff --git a/src/main/daemon/degraded-daemon-owner-recovery.ts b/src/main/daemon/degraded-daemon-owner-recovery.ts new file mode 100644 index 00000000000..ec8798a5ce3 --- /dev/null +++ b/src/main/daemon/degraded-daemon-owner-recovery.ts @@ -0,0 +1,82 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' + +export class DegradedDaemonOwnerRecovery { + private readonly attachResolver: DaemonSessionOwnerResolver + private readonly livenessResolver: DaemonSessionOwnerResolver + + constructor( + providers: readonly IPtyProvider[], + private readonly daemonAdapters: readonly DaemonPtyAdapter[], + routes: Map, + private readonly spawnFresh: (opts: PtySpawnOptions) => Promise + ) { + this.attachResolver = new DaemonSessionOwnerResolver(providers, routes) + this.livenessResolver = new DaemonSessionOwnerResolver(daemonAdapters, routes) + } + + spawn = async (opts: PtySpawnOptions): Promise => { + return opts.attachOnly && opts.sessionId + ? await this.attachResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) + : await this.spawnFresh(opts) + } + + probe = async (sessionId: string): Promise => + await this.livenessResolver.probe(sessionId) + + discoverRoutes = async (): Promise => await this.attachResolver.discoverRoutes() + + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + const alive: string[] = [] + const killed: string[] = [] + const aliveProviders = new Map>() + for (const adapter of this.daemonAdapters) { + const result = await adapter.reconcileOnStartup(validWorktreeIds) + for (const id of result.alive) { + alive.push(id) + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) + } + killed.push(...result.killed) + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.recordRoute(id, providers.values().next().value!) + } else { + this.forgetRoute(id) + } + } + return { alive, killed } + } + + recordRoute(sessionId: string, provider: IPtyProvider): void { + this.attachResolver.recordRoute(sessionId, provider) + if (this.daemonAdapters.includes(provider as DaemonPtyAdapter)) { + this.livenessResolver.recordRoute(sessionId, provider as DaemonPtyAdapter) + } + } + + forgetRoute(sessionId: string): void { + this.attachResolver.forgetRoute(sessionId) + this.livenessResolver.forgetRoute(sessionId) + } + + subscribeIdentityChanges(): (() => void)[] { + return this.daemonAdapters.flatMap((adapter) => + typeof adapter.onDaemonIdentityChanged === 'function' + ? [ + adapter.onDaemonIdentityChanged(() => { + this.attachResolver.invalidateProvider(adapter) + this.livenessResolver.invalidateProvider(adapter) + }) + ] + : [] + ) + } +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 644f732be02..af7c30d1c5d 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,6 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -158,6 +159,47 @@ it('forwards dead-endpoint write-unavailable signals from the daemon adapters', expect(recovered).toEqual(['daemon-pane', 'legacy-pane']) }) +it('routes attach-only to a legacy session created after startup inventory', async () => { + const current = createDaemonAdapter('daemon') + const legacySessions = ['legacy-at-startup'] + const legacy = createDaemonAdapter('legacy', legacySessions) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [legacy], + fallback + }) + await provider.discoverDaemonSessions() + legacySessions.push('legacy-created-later') + + await provider.spawn({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + + expect(legacy.spawn).toHaveBeenCalledOnce() + expect(current.spawn).not.toHaveBeenCalled() + expect(fallback.spawn).not.toHaveBeenCalled() +}) + +it('keeps an attach unresolved when a legacy inventory listing fails', async () => { + const current = createDaemonAdapter('daemon') + const legacy = createDaemonAdapter('legacy') + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [legacy], + fallback: createProvider('fallback') + }) + + await expect( + provider.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() +}) + it('rejects completion inspection instead of borrowing the fallback provider', async () => { const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('daemon'), @@ -368,7 +410,8 @@ describe('DegradedDaemonPtyProvider', () => { }) it('probes daemon owners without borrowing fallback liveness', async () => { - const current = createDaemonAdapter('current') + const currentSessions: string[] = [] + const current = createDaemonAdapter('current', currentSessions) const legacy = createDaemonAdapter('legacy') const fallback = createProvider('fallback', ['unknown-session']) const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) @@ -377,7 +420,7 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() expect(fallback.probePtyLiveness).not.toHaveBeenCalled() - vi.mocked(current.probePtyLiveness).mockResolvedValue(true) + currentSessions.push('unknown-session') await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) }) @@ -529,6 +572,6 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.listProcesses()).rejects.toThrow('legacy exited') expect(provider.getLegacyAdapters()).toEqual([legacy]) expect(current.listProcesses).toHaveBeenCalledTimes(3) - expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(fallback.listProcesses).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 4fef029f03b..6b1c738b316 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -14,12 +14,11 @@ import type { import { adoptOwningProvider, attachDaemonOwnedSession, - discoverDegradedDaemonSessions, findDaemonAdapter, listProviderSessionIds } from './degraded-daemon-session-routing' -import { probePtyOwners } from './daemon-pty-liveness-probe' import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing' +import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly isDegraded = true @@ -29,6 +28,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: IPtyProvider private sessionProviders = new Map() private freshSpawns: DegradedDaemonFreshSpawnRouter + private ownerRecovery: DegradedDaemonOwnerRecovery private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: PtyDataEvent) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -48,26 +48,27 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.sessionProviders, opts.probeCurrentDaemonSpawn ?? null ) + this.ownerRecovery = new DegradedDaemonOwnerRecovery( + this.allProviders(), + this.allDaemonAdapters(), + this.sessionProviders, + (spawnOpts) => this.freshSpawns.spawn(spawnOpts) + ) for (const provider of this.allProviders()) { this.unsubscribers.push( - provider.onData((payload) => { - for (const listener of this.dataListeners) { - listener(payload) - } - }), + provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))), provider.onExit((payload) => { - this.sessionProviders.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) - } + this.ownerRecovery.forgetRoute(payload.id) + this.exitListeners.forEach((listener) => listener(payload)) }) ) } + this.unsubscribers.push(...this.ownerRecovery.subscribeIdentityChanges()) } - discoverDaemonSessions(): Promise { - return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders) + async discoverDaemonSessions(): Promise { + await this.ownerRecovery.discoverRoutes() } get routesFreshSpawnsToLocalProvider(): true | undefined { @@ -82,7 +83,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { canProvideAuthoritativeBufferSnapshot = (id: string): boolean => this.freshSpawns.canProvideSnapshot(id) - spawn = (opts: PtySpawnOptions): Promise => this.freshSpawns.spawn(opts) + spawn = (opts: PtySpawnOptions): Promise => this.ownerRecovery.spawn(opts) // Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession. attach = (id: string): Promise => @@ -94,7 +95,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async probePtyLiveness(id: string): Promise { - return await probePtyOwners(id, this.sessionProviders.get(id), this.allDaemonAdapters()) + return await this.ownerRecovery.probe(id) } // Why: an unknown id cannot borrow listing authority from the fresh-spawn provider. @@ -275,20 +276,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { alive: string[] killed: string[] }> { - const alive: string[] = [] - const killed: string[] = [] - for (const adapter of this.allDaemonAdapters()) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - for (const id of result.alive) { - alive.push(id) - this.sessionProviders.set(id, adapter) - } - for (const id of result.killed) { - killed.push(id) - this.sessionProviders.delete(id) - } - } - return { alive, killed } + return await this.ownerRecovery.reconcileOnStartup(validWorktreeIds) } dispose(): void { diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts index 8f0ef61d22b..688098c05f3 100644 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ b/src/main/daemon/degraded-daemon-session-routing.ts @@ -2,21 +2,6 @@ import type { IPtyProvider } from '../providers/types' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { SessionNotFoundError } from './daemon-errors' -export async function discoverDegradedDaemonSessions( - adapters: readonly DaemonPtyAdapter[], - sessionProviders: Map -): Promise { - for (const adapter of adapters) { - try { - for (const session of await adapter.listProcesses()) { - sessionProviders.set(session.id, adapter) - } - } catch (error) { - console.warn('[daemon] Failed to discover degraded daemon sessions', error) - } - } -} - export function listProviderSessionIds( sessionProviders: ReadonlyMap, provider: IPtyProvider diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c91c7d23dfd..877d7d8862c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -14,6 +14,7 @@ import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -9009,14 +9010,15 @@ describe('registerPtyHandlers', () => { } ) - it('adopts an exact persisted owner when the runtime projection is missing', async () => { + it('repairs a stale persisted incarnation after exact same-id reattach', async () => { const tabId = 'tab-persisted-owner' const leafId = '88888888-8888-4888-8888-888888888888' const paneKey = makePaneKey(tabId, leafId) const worktreeId = 'repo-1::/tmp/persisted-owner' + let attachAttempt = 0 const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({ id: options.sessionId ?? 'unexpected-fresh-id', - incarnationId: 'inc-persisted-owner', + incarnationId: attachAttempt++ === 1 ? 'inc-wrong-owner' : 'inc-live-owner', isReattach: options.attachOnly === true, snapshot: 'persisted-owner-output' })) @@ -9067,10 +9069,10 @@ describe('registerPtyHandlers', () => { [tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } } }, terminalPtyIncarnationsByPaneKey: { - [paneKey]: 'inc-persisted-owner' + [paneKey]: 'inc-stale-owner' } })), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(() => true) } registerPtyHandlers( @@ -9081,7 +9083,7 @@ describe('registerPtyHandlers', () => { undefined, store as never ) - const mounted = await handlers.get('pty:spawn')!(null, { + const spawnArgs = { cols: 80, rows: 24, cwd: '/tmp/persisted-owner', @@ -9094,18 +9096,22 @@ describe('registerPtyHandlers', () => { ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } - }) + } + + const mounted = await handlers.get('pty:spawn')!(null, spawnArgs) expect(mounted).toMatchObject({ id: 'pty-persisted-owner', - incarnationId: 'inc-persisted-owner', + incarnationId: 'inc-live-owner', isReattach: true }) - expect(providerSpawn).toHaveBeenCalledOnce() - expect(providerSpawn).toHaveBeenCalledWith( + expect(providerSpawn).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ attachOnly: true, sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-stale-owner', + expectedIncarnationIsAuthoritative: false, command: undefined }) ) @@ -9115,10 +9121,50 @@ describe('registerPtyHandlers', () => { ) expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled() expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-live-owner', + expectedBinding: { + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-stale-owner' + } + }), + undefined + ) expect( mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned') ).toHaveLength(1) expect(runtime.onPtyExit).not.toHaveBeenCalled() + + store.persistPtyBinding.mockClear() + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + + runtime.assertPtyRegistrationAllowed.mockImplementationOnce(() => { + throw new Error('agent_session_exited_during_start') + }) + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'agent_session_exited_during_start' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledTimes(3) + expect(providerSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + attachOnly: true, + sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-live-owner', + expectedIncarnationIsAuthoritative: true, + command: undefined + }) + ) + clearProviderPtyState('pty-persisted-owner') }) it.each([ @@ -9295,29 +9341,22 @@ describe('registerPtyHandlers', () => { } ) - it.each([ - { label: 'another owner reports it alive', liveness: true }, - { label: 'no owner could answer', liveness: null } - ])('keeps a persisted owner whose absence is unproven ($label)', async ({ liveness }) => { + it('keeps a persisted owner when daemon routing is unresolved', async () => { const worktreeId = 'repo-1::/tmp/unproven-owner' const cwd = '/tmp/unproven-owner' const tabId = 'tab-unproven-owner' const leafId = '56565656-5656-4656-8656-565656565656' const paneKey = makePaneKey(tabId, leafId) - // Why: a degraded router answers unmapped ids from the local fallback, which never - // owned this daemon session — the same "Session not found" a truly dead PTY yields. const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-unproven-owner') + throw new TerminalSessionOwnerUnverifiedError('pty-unproven-owner') } return { id: 'pty-fresh-unproven', incarnationId: 'inc-fresh-unproven' } } ) - const probePtyLiveness = vi.fn(async () => liveness) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9410,7 +9449,6 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow('terminal_pane_owner_unverified') - expect(probePtyLiveness).toHaveBeenCalledWith('pty-unproven-owner') // The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated. expect(providerSpawn).toHaveBeenCalledOnce() expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true }) @@ -9420,11 +9458,7 @@ describe('registerPtyHandlers', () => { expect(session.tabsByWorktree[worktreeId]).toHaveLength(1) }) - // Why the positive direction needs its own case: the sibling retire tests use providers with - // no `probePtyLiveness`, so they skip this guard entirely. Without this, the guard could be - // strengthened into a permanent veto — no daemon-backed pane could ever recover from a dead - // owner — and every suite would stay green. - it('still retires and respawns when a provider proves the owner is absent', async () => { + it('still retires and respawns when the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/proven-absent-owner' const cwd = '/tmp/proven-absent-owner' const tabId = 'tab-proven-absent-owner' @@ -9438,10 +9472,8 @@ describe('registerPtyHandlers', () => { return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' } } ) - const probePtyLiveness = vi.fn(async () => false) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9532,8 +9564,6 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-proven-absent-owner') - // Proven absence is the one answer that authorizes retirement, so recovery must proceed. expect(mounted).toMatchObject({ id: 'pty-fresh-proven' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ @@ -9548,6 +9578,124 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() }) + it('does not poll after the routed provider confirms absence', async () => { + const worktreeId = 'repo-1::/tmp/probe-blip-owner' + const cwd = '/tmp/probe-blip-owner' + const tabId = 'tab-probe-blip-owner' + const leafId = '67676767-6767-4767-8767-676767676767' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + throw new Error('Session not found: pty-probe-blip-owner') + } + return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' } + } + ) + const probePtyLiveness = vi.fn(async () => null) + setLocalPtyProvider({ + spawn: providerSpawn, + probePtyLiveness, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-probe-blip-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-probe-blip-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-probe-blip'), + preAllocateHandleForPty: vi.fn(() => 'term-probe-blip'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume probe-blip-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(probePtyLiveness).not.toHaveBeenCalled() + expect(mounted).toMatchObject({ id: 'pty-fresh-probe-blip' }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-probe-blip-owner', + 0, + 'inc-probe-blip-owner' + ) + }) + // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while // persistence has already dropped it. Reading "nothing left to retire" as a competing owner // aborted materialization *after* signalling the exit, which destroyed the pane instead of @@ -9676,7 +9824,7 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner') + expect(probePtyLiveness).not.toHaveBeenCalled() expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e4f50a62501..5d858cac7f3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -93,6 +93,7 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -599,6 +600,9 @@ type StablePaneOwner = { leafId: string ptyId: string incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string } type StablePaneAdoption = { result: PtySpawnResult @@ -677,13 +681,6 @@ function resolveStablePaneOwner( throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - if ( - runtimeIncarnationId && - persisted?.incarnationId && - runtimeIncarnationId !== persisted.incarnationId - ) { - throw new Error('terminal_pane_owner_conflict') - } const parsed = parsePaneKey(paneKey) if (!parsed) { return null @@ -695,7 +692,10 @@ function resolveStablePaneOwner( ptyId, ...(runtimeIncarnationId || persisted?.incarnationId ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}) + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) } } @@ -717,7 +717,7 @@ function retirePersistedStablePaneOwner( // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. return true } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { return false } const session = store.getWorkspaceSession(hostId) @@ -726,7 +726,7 @@ function retirePersistedStablePaneOwner( parentTabId: owner.tabId, leafId: owner.leafId, ptyId: owner.ptyId, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) }) if (retired === session) { return false @@ -748,6 +748,47 @@ type StablePaneSpawnContext = { onFreshSpawn?: (result: PtySpawnResult) => void } +function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { @@ -758,6 +799,8 @@ async function attachStablePaneOwner( ...spawnOptions, sessionId: owner.ptyId, attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, isNewSession: undefined, command: undefined, commandDelivery: undefined, @@ -769,25 +812,19 @@ async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } if (!isPtyAlreadyGoneError(error)) { throw error } - // Why: "Session not found" only proves the provider we asked has no such PTY — and a - // degraded router answers unmapped ids from the local fallback, which never owned a - // daemon session. Retiring on that would signal exit and delete a live agent's pane - // binding. Absence must be proven across every possible owner first; `null` (nobody - // could answer) is not absence. Providers without a probe are their own sole owner, - // so their refusal stays authoritative. - if (provider.probePtyLiveness && (await provider.probePtyLiveness(owner.ptyId)) !== false) { - throw new Error('terminal_pane_owner_unverified') - } const ownerBeforeRetire = args.resolveOwner?.() if ( ownerBeforeRetire && (ownerBeforeRetire.ptyId !== owner.ptyId || - (ownerBeforeRetire.incarnationId !== undefined && - owner.incarnationId !== undefined && - ownerBeforeRetire.incarnationId !== owner.incarnationId)) + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) ) { throw new Error('terminal_pane_owner_changed') } @@ -808,7 +845,9 @@ async function attachStablePaneOwner( if ( result.id !== owner.ptyId || result.isReattach !== true || - (owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId) + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) ) { throw new Error('terminal_pane_owner_changed') } @@ -4680,6 +4719,7 @@ export function registerPtyHandlers( : null let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -4902,6 +4942,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(args.preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: hostSessionBinding?.store, + owner: stablePaneOwner, + result, + worktreeId: hostSessionBinding?.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } if (result.agentSessionEnsure?.disposition === 'adopted') { const owner = result.agentSessionEnsure.owner ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) @@ -4969,7 +5027,7 @@ export function registerPtyHandlers( target: codexSelectionTarget, settings: getSettings?.() }) - if (hostSessionBinding) { + if (hostSessionBinding && !stablePaneBindingPersisted) { try { const binding = { worktreeId: hostSessionBinding.worktreeId, @@ -5625,6 +5683,7 @@ export function registerPtyHandlers( let finishTerminalInstall = (): void => {} let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -6245,6 +6304,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store, + owner: stablePaneOwner, + result, + worktreeId: args.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } spawnTiming.log(result.id, { daemon: isDaemonHostSpawn, reattach: result.isReattach ?? false @@ -6303,7 +6380,8 @@ export function registerPtyHandlers( store && typeof args.worktreeId === 'string' && typeof args.tabId === 'string' && - validatedLeafId !== null + validatedLeafId !== null && + !stablePaneBindingPersisted ) { try { const binding = { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 24cc6e512cc..0a48bbbb343 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9033,6 +9033,221 @@ describe('Store', () => { ).toBeUndefined() }) + it('reconciles only the incarnation of an unchanged durable PTY binding', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toBe(true) + + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('inc-live') + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + }) + + it('rejects competing PTY and incarnation changes during reconciliation', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-current' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-current' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-current' } + }) + + for (const competing of [ + { ptyId: 'pty-replaced', expectedIncarnationId: 'inc-current' }, + { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } + ]) { + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: competing.ptyId, + incarnationId: 'inc-live', + expectedBinding: { + ptyId: competing.ptyId, + incarnationId: competing.expectedIncarnationId + } + }) + ).toBe(false) + } + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-current' + ) + }) + + it('reconciles only the requested execution-host partition', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + } + store.setWorkspaceSession(structuredClone(session), 'local') + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-remote-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + expect(store.getWorkspaceSession('local').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + expect(store.getWorkspaceSession('ssh:ssh-1').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-remote-live' + ) + }) + + it.each([ + { label: 'local', hostId: undefined }, + { label: 'SSH', hostId: 'ssh:ssh-1' } + ])( + 'preserves a reconciled incarnation across a renderer snapshot ($label)', + async ({ hostId }) => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }, + hostId + ) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + hostId + ) + ).toBe(true) + + const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId)) + delete rendererSnapshot.terminalPtyIncarnationsByPaneKey + delete rendererSnapshot.terminalTopologyRevisionByRepoId + store.setWorkspaceSession(rendererSnapshot, hostId) + + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + } + ) + + it('rolls back incarnation reconciliation when the durability barrier fails', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk full') + }) + + expect(() => + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toThrow('disk full') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + }) + it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { const store = await createStore() store.setWorkspaceSession({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index af82bcbdd89..be3f10fd78f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6621,11 +6621,26 @@ export class Store { ptyId: string incarnationId?: string startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } }, hostId?: string | null - ): void { + ): boolean { const resolvedHostId = this.resolveHostId(hostId) const session = this.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[args.worktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, @@ -6633,15 +6648,17 @@ export class Store { } } const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const paneKey = `${args.tabId}:${args.leafId}` + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - const advanceTopologyAfterMembershipChange = (): void => { + const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(args.worktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (!terminalMembershipChanged || currentRevision <= 0) { + if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) { return } - // Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay. + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, [repoId]: currentRevision + 1 @@ -6696,14 +6713,14 @@ export class Store { } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } - return + return true } const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { @@ -6744,13 +6761,14 @@ export class Store { } } } - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } + return true } // ── SSH Targets ──────────────────────────────────────────────────── diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d43158482c0..64dd9850930 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -546,7 +547,7 @@ export class LocalPtyProvider implements IPtyProvider { } } if (args.attachOnly) { - throw new Error(`Session not found: ${args.sessionId ?? ''}`) + throw new SessionNotFoundError(args.sessionId ?? '') } const id = allocatePtyId(reattachId ?? undefined) const incarnationId = randomUUID() diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index b766b11497d..98ab3d9b69b 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -64,6 +64,10 @@ export type PtySpawnOptions = { isNewSession?: boolean /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean + /** Exact persisted owner expected by an attach-only routing decision. */ + expectedIncarnationId?: PtyIncarnationId + /** True when runtime state makes the expected incarnation a hard attach fence. */ + expectedIncarnationIsAuthoritative?: boolean /** Why: allows the renderer to request a specific shell for a single new * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without * changing the user's persistent default shell setting. Only consulted on diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 56e7ffd0afc..bd9aedd375c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + humanizeTerminalError, isSshReconnectOwnedTerminalError, shouldOfferDaemonRestart, stripSshReconnectOwnedErrorLines @@ -36,6 +37,24 @@ describe('isSshReconnectOwnedTerminalError', () => { }) }) +describe('humanizeTerminalError', () => { + it('replaces the pane-owner-unverified code with actionable copy', () => { + const humanized = humanizeTerminalError('terminal_pane_owner_unverified') + expect(humanized).not.toContain('terminal_pane_owner_unverified') + expect(humanized).toContain('Reopen this pane to retry') + }) + + it('humanizes an IPC-wrapped pane-owner-unverified error', () => { + const wrapped = + "Error invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified" + expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified') + }) + + it('leaves other errors untouched', () => { + expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.') + }) +}) + describe('stripSshReconnectOwnedErrorLines', () => { it('clears an error that is only SSH reconnect text', () => { expect(stripSshReconnectOwnedErrorLines(SSH_FAILURE)).toBeNull() diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 7f38733fa49..99a00e54f9a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -12,6 +12,8 @@ const STALE_DAEMON_CWD_MARKERS = [ "Daemon's working directory is gone", 'node-pty: daemon_cwd failed: ENOENT' ] +// Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536). +const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified' function isSshError(error: string): boolean { return error.startsWith(SSH_PREFIX) || error.includes(SSH_RELAY_LOST_MARKER) @@ -42,6 +44,20 @@ export function shouldOfferDaemonRestart(error: string): boolean { ) } +/** Swaps the raw pane-owner-unverified code for copy a user can act on. */ +export function humanizeTerminalError(error: string): string { + if (!error.includes(PANE_OWNER_UNVERIFIED_MARKER)) { + return error + } + return error.replace( + PANE_OWNER_UNVERIFIED_MARKER, + translate( + 'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db', + "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." + ) + ) +} + export function TerminalErrorToast({ error, onDismiss, @@ -53,6 +69,7 @@ export function TerminalErrorToast({ }): React.JSX.Element { const ssh = isSshError(error) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) + const displayError = humanizeTerminalError(error) return (
- {error} + {displayError} {showDaemonRestart ? ( <> {'\n'} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3f940076736..3792f691513 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2801,7 +2801,8 @@ "e4aa243f8c": "Restart daemon", "a7e2fd2699": "file an issue", "5c8ce20be6": "If this persists, please", - "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state." + "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.", + "7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." }, "TerminalPane": { "ac112e9036": "Remove title", From b5218374819973c26187c7c5c7e76048028835f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:06:15 -0700 Subject: [PATCH 25/88] fix(mobile): keep hosts visible when credentials are unavailable (#12775) * fix(mobile): keep hosts visible when credentials are unavailable * fix(mobile): guard unavailable host recovery * fix(mobile): protect replacement credential writes * fix(mobile): retain superseded cleanup intents * fix(mobile): preserve credential cleanup authority * fix(mobile): make host cleanup crash-safe --- mobile/app/_layout.tsx | 9 +- mobile/app/index.tsx | 62 ++- mobile/src/components/MobileHostCard.test.tsx | 28 +- mobile/src/components/MobileHostCard.tsx | 46 ++- .../notification-routing.test.ts | 34 +- .../src/notifications/notification-routing.ts | 20 +- .../use-open-notification-route.ts | 10 +- .../transport/host-catalog-credential-join.ts | 52 +++ .../transport/host-catalog-selection.test.ts | 39 ++ .../src/transport/host-catalog-selection.ts | 7 + .../transport/host-credential-cleanup.test.ts | 20 + .../src/transport/host-credential-cleanup.ts | 11 +- .../host-credential-write-revision.ts | 21 + .../src/transport/host-device-token-store.ts | 43 ++ .../src/transport/host-list-load-sharing.ts | 13 +- mobile/src/transport/host-metadata-store.ts | 51 +++ mobile/src/transport/host-store.test.ts | 331 +++++++++++++++- mobile/src/transport/host-store.ts | 370 +++++++++--------- .../mobile-relay-credential-bundle.ts | 2 + .../mobile-relay-direct-upgrade-journal.ts | 2 + mobile/src/transport/types.ts | 7 + .../unpaired-host-credential-deletion.ts | 58 +++ 22 files changed, 1010 insertions(+), 226 deletions(-) create mode 100644 mobile/src/transport/host-catalog-credential-join.ts create mode 100644 mobile/src/transport/host-catalog-selection.test.ts create mode 100644 mobile/src/transport/host-catalog-selection.ts create mode 100644 mobile/src/transport/host-credential-write-revision.ts create mode 100644 mobile/src/transport/host-device-token-store.ts create mode 100644 mobile/src/transport/host-metadata-store.ts create mode 100644 mobile/src/transport/unpaired-host-credential-deletion.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1292b6e5f5f..9080cdedcf9 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -95,9 +95,12 @@ export default function RootLayout() { } async function getNavigationTarget(data: unknown) { - const hosts = await loadHosts().catch(() => null) + const hosts = await loadHostCatalog().catch(() => null) return getNotificationNavigationTarget(data, { - knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined + knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, + credentialStatusByHostId: hosts + ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) + : undefined }) } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 642cbfac539..1a4681565e3 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -15,7 +15,8 @@ import { UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' +import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection' import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' @@ -32,7 +33,7 @@ import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' -import type { ConnectionState, HostProfile } from '../src/transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileHostCard } from '../src/components/MobileHostCard' @@ -224,9 +225,9 @@ export default function HomeScreen() { const insets = useSafeAreaInsets() // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. const { isWideLayout, contentMaxWidth } = useResponsiveLayout() - const [hosts, setHosts] = useState([]) + const [hostCatalog, setHostCatalog] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(null) + const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) @@ -242,6 +243,7 @@ export default function HomeScreen() { const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. + const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) @@ -308,12 +310,12 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then(async (h) => { + void loadHostCatalog().then(async (catalog) => { if (stale) { return } - setHosts(h) - if (h.length === 0 || onboardingOptInCheckedRef.current) { + setHostCatalog(catalog) + if (catalog.length === 0 || onboardingOptInCheckedRef.current) { return } onboardingOptInCheckedRef.current = true @@ -351,6 +353,10 @@ export default function HomeScreen() { () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), [hosts] ) + const sortedHostCatalog = useMemo( + () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), + [hostCatalog] + ) // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { @@ -389,17 +395,24 @@ export default function HomeScreen() { } } // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hosts) { + for (const host of hostCatalog) { if (liveIds.has(host.id)) { continue } - if (!host.publicKeyB64 || !host.deviceToken) { + if (host.credentialStatus === 'missing') { if (next[host.id] !== 'auth-failed') { next[host.id] = 'auth-failed' changed = true } continue } + if (host.credentialStatus === 'temporarily-unavailable') { + if (next[host.id] !== 'disconnected') { + next[host.id] = 'disconnected' + changed = true + } + continue + } const prevState = next[host.id] if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { next[host.id] = 'disconnected' @@ -408,14 +421,14 @@ export default function HomeScreen() { } // Drop entries for hosts we no longer track at all. for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) { + if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { delete next[id] changed = true } } return changed ? next : prev }) - }, [allClients, hosts]) + }, [allClients, hostCatalog]) // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the @@ -629,7 +642,7 @@ export default function HomeScreen() { try { await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) - setHosts(await loadHosts()) + setHostCatalog(await loadHostCatalog()) } catch { // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) @@ -655,7 +668,7 @@ export default function HomeScreen() { - {hosts.length === 0 ? ( + {hostCatalog.length === 0 ? ( /* ─── Empty state: onboarding ─── */ h.id} // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ @@ -744,14 +757,29 @@ export default function HomeScreen() { return ( router.push(`/h/${item.id}`)} + onPress={() => { + if (item.credentialStatus === 'missing') { + router.push('/pair-scan') + } else if (item.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(setHostCatalog) + .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) + } else { + router.push(`/h/${item.id}`) + } + }} onLongPress={() => { triggerMediumImpact() - setActionTarget(item) + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } }} /> ) @@ -928,7 +956,7 @@ export default function HomeScreen() { onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: closeHostClient, onEdit: openMobileHostEdit, - onRemove: setConfirmRemove + onRemove: (host) => setConfirmRemove(host) })} onClose={() => setActionTarget(null)} /> diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 6138e311127..5a6855f71dc 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo @@ -54,6 +54,7 @@ describe('MobileHostCard', () => { state?: ConnectionState verdict?: ConnectionVerdict path?: MobileConnectionPath + credentialStatus?: HostCredentialStatus } ): Promise { await act(async () => { @@ -63,6 +64,7 @@ describe('MobileHostCard', () => { state: overrides?.state ?? 'connected', verdict: overrides?.verdict ?? verdict, path: overrides?.path ?? 'lan', + credentialStatus: overrides?.credentialStatus, worktreeInfo, onPress: () => {}, onLongPress: () => {} @@ -139,4 +141,28 @@ describe('MobileHostCard', () => { expect(lines).not.toContain('0 worktrees') expect(lines).not.toContain('Worktree list unavailable') }) + + it('offers re-pairing when the credential is missing', async () => { + const lines = await renderCard(loaded, { + state: 'connected', + verdict: { kind: 'auth-failed', label: 'Pairing invalid' }, + credentialStatus: 'missing' + }) + + expect(lines).toContain('Pairing invalid') + expect(lines).toContain('Tap to re-pair with your desktop') + expect(lines).not.toContain('12 worktrees · 2 active') + }) + + it('offers a retry without declaring a transient read failure invalid', async () => { + const lines = await renderCard(undefined, { + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + credentialStatus: 'temporarily-unavailable' + }) + + expect(lines).toContain('Pairing temporarily unavailable') + expect(lines).toContain('Unlock your phone, then tap to retry') + expect(lines).not.toContain('Pairing invalid') + }) }) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index f81f936405a..f52f917e22a 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -4,13 +4,14 @@ import type { ConnectionVerdict } from '../transport/connection-health' import { verdictDisplayLabel } from '../transport/connection-health' import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { - host: HostProfile + host: HostProfile | HostCatalogEntry + credentialStatus?: HostCatalogEntry['credentialStatus'] state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath @@ -20,13 +21,26 @@ export function MobileHostCard(props: { onPress: () => void onLongPress: () => void }) { - const connected = props.state === 'connected' + const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' + const credentialMissing = props.credentialStatus === 'missing' + const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. const dialingPath = ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' - const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const isError = + credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const statusLabel = credentialMissing + ? 'Pairing invalid' + : credentialUnavailable + ? 'Pairing temporarily unavailable' + : verdictDisplayLabel(props.verdict) + const statusVerdict: ConnectionVerdict = credentialMissing + ? { kind: 'auth-failed', label: statusLabel } + : credentialUnavailable + ? { kind: 'warning', label: statusLabel } + : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( - - - {verdictDisplayLabel(props.verdict)} - {connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} + + + {statusLabel} + {!credentialMissing && !credentialUnavailable && (connected || dialingPath) + ? ` · ${mobileConnectionPathLabel(props.path)}` + : ''} {connected && worktreeSummary ? ( @@ -62,6 +85,13 @@ export function MobileHostCard(props: { Update desktop Orca and sign in to connect from anywhere ) : null} + {credentialMissing || credentialUnavailable ? ( + + {credentialMissing + ? 'Tap to re-pair with your desktop' + : 'Unlock your phone, then tap to retry'} + + ) : null} diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 6b5762aefb4..779aa2425e5 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing' +import { + buildLocalNotificationData, + getNotificationNavigationTarget, + notificationCredentialRecoveryRoute +} from './notification-routing' describe('notification routing', () => { it('includes the host id in locally scheduled notification data', () => { @@ -55,4 +59,32 @@ describe('notification routing', () => { ) ).toBeNull() }) + + it.each([ + ['missing', 're-pair'], + ['temporarily-unavailable', 'retry'] + ] as const)('routes %s host credentials to %s recovery', (status, recovery) => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { + knownHostIds: new Set(['host-1']), + credentialStatusByHostId: new Map([['host-1', status]]) + } + ) + + expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery }) + expect(notificationCredentialRecoveryRoute(target!)).toBe( + status === 'missing' ? '/pair-scan' : '/' + ) + }) + + it('keeps ready hosts on the requested notification destination', () => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { credentialStatusByHostId: new Map([['host-1', 'ready']]) } + ) + + expect(target?.sessionTarget).not.toBeNull() + expect(notificationCredentialRecoveryRoute(target!)).toBeNull() + }) }) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index feda52c596b..5f81fb3567d 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,5 +1,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' +import type { HostCredentialStatus } from '../transport/types' export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' @@ -18,6 +19,7 @@ export type LocalNotificationData = { export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet + credentialStatusByHostId?: ReadonlyMap } function readNonEmptyString(value: unknown): string | null { @@ -46,8 +48,18 @@ export function buildLocalNotificationData( export type NotificationNavigationTarget = Readonly<{ hostId: string sessionTarget: HostStackRouteTarget | null + credentialRecovery?: 'retry' | 're-pair' }> +export function notificationCredentialRecoveryRoute( + target: NotificationNavigationTarget +): '/' | '/pair-scan' | null { + if (target.credentialRecovery === 're-pair') { + return '/pair-scan' + } + return target.credentialRecovery === 'retry' ? '/' : null +} + export function getNotificationNavigationTarget( data: unknown, options: NotificationNavigationOptions = {} @@ -66,8 +78,14 @@ export function getNotificationNavigationTarget( } const worktreeId = readNonEmptyString(record.worktreeId) + const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null + sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + ...(credentialStatus === 'missing' + ? { credentialRecovery: 're-pair' as const } + : credentialStatus === 'temporarily-unavailable' + ? { credentialRecovery: 'retry' as const } + : {}) } } diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts index 3a0809c7be8..31d4715c43e 100644 --- a/mobile/src/notifications/use-open-notification-route.ts +++ b/mobile/src/notifications/use-open-notification-route.ts @@ -2,7 +2,10 @@ import { useCallback } from 'react' import { useRouter } from 'expo-router' import { hostStackHostRoute } from '../navigation/host-stack-navigation' import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import type { NotificationNavigationTarget } from './notification-routing' +import { + notificationCredentialRecoveryRoute, + type NotificationNavigationTarget +} from './notification-routing' export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { const openHostStackRoute = useOpenHostStackRoute() @@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge return useCallback( (target) => { + const recoveryRoute = notificationCredentialRecoveryRoute(target) + if (recoveryRoute) { + router.push(recoveryRoute) + return + } if (target.sessionTarget) { openHostStackRoute(target.hostId, target.sessionTarget) return diff --git a/mobile/src/transport/host-catalog-credential-join.ts b/mobile/src/transport/host-catalog-credential-join.ts new file mode 100644 index 00000000000..e914742ff4e --- /dev/null +++ b/mobile/src/transport/host-catalog-credential-join.ts @@ -0,0 +1,52 @@ +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import type { HostListSnapshot } from './host-list-load-sharing' +import type { HostCatalogEntry, StoredHostProfile } from './types' + +export async function joinHostCatalogCredentials(args: { + storedHosts: readonly StoredHostProfile[] + overlays: ReadonlyMap + tokenCache: Map + readToken: (hostId: string) => Promise + getRevision: () => number +}): Promise { + const catalog: HostCatalogEntry[] = [] + const profiles: HostListSnapshot['profiles'] = [] + for (const stored of args.storedHosts) { + let token = args.tokenCache.get(stored.id) + let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready' + if (!token) { + const readRevision = args.getRevision() + let fetched: string | null + try { + fetched = await args.readToken(stored.id) + } catch { + credentialStatus = 'temporarily-unavailable' + fetched = null + } + if (!fetched && credentialStatus === 'ready') { + credentialStatus = 'missing' + } + token = fetched ?? undefined + if (token && readRevision === args.getRevision()) { + args.tokenCache.set(stored.id, token) + } + } + const overlay = args.overlays.get(stored.id) + const base = { + ...stored, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + } + const profile = token ? { ...base, deviceToken: token } : null + catalog.push({ ...base, credentialStatus, profile }) + if (profile) { + profiles.push(profile) + } + } + return { catalog, profiles } +} diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts new file mode 100644 index 00000000000..5f5d85ca787 --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { selectConnectableHostProfiles } from './host-catalog-selection' +import type { HostCatalogEntry, HostProfile } from './types' + +const profile: HostProfile = { + id: 'host-ready', + name: 'Ready', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key-ready', + lastConnected: 0 +} + +function unavailable( + id: string, + credentialStatus: 'temporarily-unavailable' | 'missing' +): HostCatalogEntry { + return { + id, + name: id, + endpoint: `ws://127.0.0.1/${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 0, + credentialStatus, + profile: null + } +} + +describe('selectConnectableHostProfiles', () => { + it('excludes unavailable catalog entries from connection priming', () => { + expect( + selectConnectableHostProfiles([ + { ...profile, credentialStatus: 'ready', profile }, + unavailable('host-locked', 'temporarily-unavailable'), + unavailable('host-missing', 'missing') + ]) + ).toEqual([profile]) + }) +}) diff --git a/mobile/src/transport/host-catalog-selection.ts b/mobile/src/transport/host-catalog-selection.ts new file mode 100644 index 00000000000..31477dd7f6d --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.ts @@ -0,0 +1,7 @@ +import type { HostCatalogEntry, HostProfile } from './types' + +export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] { + return catalog.flatMap((entry) => + entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : [] + ) +} diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index bd483a3280f..146a845a96b 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,8 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelPendingHostCredentialCleanup, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, + recordHostCredentialCleanupIntent, resetHostCredentialCleanupForTests, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup, @@ -66,6 +68,12 @@ describe('host credential cleanup', () => { expect(deleteCredential).toHaveBeenCalledOnce() }) + it('records cleanup intent without starting native deletion', async () => { + await recordHostCredentialCleanupIntent('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + }) + it('persists cleanup intent before a rejected delete for manual retry', async () => { const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) const listener = vi.fn() @@ -78,6 +86,18 @@ describe('host credential cleanup', () => { unsubscribe() }) + it('cancels stale cleanup intent after replacement publication', async () => { + const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded')) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + + await cancelPendingHostCredentialCleanup('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + it('records intent before a stalled delete times out', async () => { vi.useFakeTimers() const deleteCredential = vi.fn(() => new Promise(() => undefined)) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index d64e37a98dc..96a2623b514 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia async function recordCleanupIntent(hostId: string): Promise { try { - await addPendingId(hostId) + await recordHostCredentialCleanupIntent(hostId) return true } catch { return false } } +export async function recordHostCredentialCleanupIntent(hostId: string): Promise { + await addPendingId(hostId) +} + async function confirmNativeCleanup( hostId: string, deleteCredential: DeleteHostCredential, @@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): () return () => pendingListeners.delete(listener) } +export async function cancelPendingHostCredentialCleanup(hostId: string): Promise { + clearUnrecordedPending(hostId) + await removePendingId(hostId) +} + /** * Record cleanup intent, then fire-and-forget the native keychain delete so * removeHost never blocks on SecureStore. If the durable intent write fails, diff --git a/mobile/src/transport/host-credential-write-revision.ts b/mobile/src/transport/host-credential-write-revision.ts new file mode 100644 index 00000000000..12207aa6a12 --- /dev/null +++ b/mobile/src/transport/host-credential-write-revision.ts @@ -0,0 +1,21 @@ +let nextRevision = 1 +const revisionByHostId = new Map() + +export function getHostCredentialWriteRevision(hostId: string): number { + return revisionByHostId.get(hostId) ?? 0 +} + +export function markHostCredentialWrite(hostId: string): void { + revisionByHostId.set(hostId, nextRevision) + nextRevision += 1 +} + +export function clearHostCredentialWriteRevision(hostId: string): void { + revisionByHostId.delete(hostId) +} + +/** Test-only: clear session credential generations between cases. */ +export function resetHostCredentialWriteRevisionsForTests(): void { + nextRevision = 1 + revisionByHostId.clear() +} diff --git a/mobile/src/transport/host-device-token-store.ts b/mobile/src/transport/host-device-token-store.ts new file mode 100644 index 00000000000..297177e0dbe --- /dev/null +++ b/mobile/src/transport/host-device-token-store.ts @@ -0,0 +1,43 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' + +// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' +const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +function webTokenKey(hostId: string): string { + return `${WEB_TOKEN_KEY_PREFIX}${hostId}` +} + +export async function readHostDeviceToken(hostId: string): Promise { + // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. + if (Platform.OS === 'web') { + return AsyncStorage.getItem(webTokenKey(hostId)) + } + return readPairingKeychainItem(tokenKey(hostId)) +} + +export async function writeHostDeviceToken(hostId: string, token: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.setItem(webTokenKey(hostId), token) + return + } + await writePairingKeychainItem(tokenKey(hostId), token) +} + +export async function deleteHostDeviceToken(hostId: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.removeItem(webTokenKey(hostId)) + return + } + await deletePairingKeychainItem(tokenKey(hostId)) +} diff --git a/mobile/src/transport/host-list-load-sharing.ts b/mobile/src/transport/host-list-load-sharing.ts index ab1e9e2fe89..15995e3ba25 100644 --- a/mobile/src/transport/host-list-load-sharing.ts +++ b/mobile/src/transport/host-list-load-sharing.ts @@ -1,15 +1,22 @@ -import type { HostProfile } from './types' +import type { HostCatalogEntry, HostProfile } from './types' + +export type HostListSnapshot = { + catalog: HostCatalogEntry[] + profiles: HostProfile[] +} // Why: concurrent callers share a slow Keychain pass; durable writes invalidate // it so later loads cannot receive stale snapshots (#8791). -let inflight: Promise | null = null +let inflight: Promise | null = null let revision = 0 export function getHostListLoadRevision(): number { return revision } -export function shareHostListLoad(load: () => Promise): Promise { +export function shareHostListLoad( + load: () => Promise +): Promise { if (inflight) { return inflight } diff --git a/mobile/src/transport/host-metadata-store.ts b/mobile/src/transport/host-metadata-store.ts new file mode 100644 index 00000000000..7962f43dc37 --- /dev/null +++ b/mobile/src/transport/host-metadata-store.ts @@ -0,0 +1,51 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types' + +const STORAGE_KEY = 'orca:hosts' + +export async function loadStoredHostProfiles(): Promise { + return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY)) +} + +export async function readStoredHostProfilesForMutation(): Promise { + try { + const parsed = await loadStoredHostProfiles() + if (parsed) { + return parsed + } + } catch { + // Normalize storage and payload failures for fail-closed mutations. + } + throw new Error('host list storage unreadable') +} + +export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise { + return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) +} + +export function toStoredHostProfile(host: HostProfile): StoredHostProfile { + const { id, name, endpoint, publicKeyB64, lastConnected } = host + return { id, name, endpoint, publicKeyB64, lastConnected } +} + +function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 997e8a3d42f..ddb8f92a92b 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const cancelCleanupMock = vi.hoisted(() => vi.fn()) +const recordCleanupIntentMock = vi.hoisted(() => vi.fn()) const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -30,11 +32,14 @@ vi.mock('react-native', () => ({ })) vi.mock('./host-credential-cleanup', () => ({ + cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args), + recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args), scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args), retryPendingHostCredentialCleanups: vi.fn() })) import { + loadHostCatalog, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -46,6 +51,7 @@ import { updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -63,6 +69,23 @@ const HOST_TWO = { publicKeyB64: 'key-2', lastConnected: 0 } +const HOST_ONE_RELAY_BUNDLE = { + v: 1 as const, + hostId: HOST_ONE.id, + deviceToken: 'replacement-token', + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 10_000 + } +} + +function scheduledCleanup(hostId: string): (id: string) => Promise { + const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1] + expect(cleanup).toBeTypeOf('function') + return cleanup as (id: string) => Promise +} describe('host-store list mutations', () => { let storedHostsRaw: string @@ -75,6 +98,10 @@ describe('host-store list mutations', () => { resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { @@ -93,6 +120,7 @@ describe('host-store list mutations', () => { storedOverlayRaw = raw } }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null ) @@ -114,6 +142,55 @@ describe('host-store list mutations', () => { expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() }) + it('keeps a host visible when its credential read temporarily fails', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => { + if (key.endsWith(HOST_ONE.id)) { + throw new Error('keychain locked') + } + return key.endsWith(HOST_TWO.id) ? 'token-2' : null + }) + + const catalog = await loadHostCatalog() + const profiles = await loadHosts() + + expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id]) + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'temporarily-unavailable', + profile: null + }) + expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id]) + }) + + it('keeps orphaned metadata visible when its credential is missing', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_TWO.id) ? 'token-2' : null + ) + + const catalog = await loadHostCatalog() + + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'missing', + profile: null + }) + }) + + it('keeps existing metadata when pairing a distinct host', async () => { + await saveHost({ + id: 'host-new', + name: 'Host 3', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'token-new', + lastConnected: 0 + }) + + expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([ + HOST_ONE.id, + HOST_TWO.id, + 'host-new' + ]) + }) + it('fails closed when durable host identity storage is unreadable', async () => { asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) @@ -134,9 +211,173 @@ describe('host-store list mutations', () => { }) expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) }) + it('does not remove same-key metadata when the replacement credential write fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('keychain write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id) + expect(scheduleCleanupMock).not.toHaveBeenCalled() + }) + + it('serves a committed replacement token when same-key metadata persistence fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await loadHosts() + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + await expect( + loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken) + ).resolves.toBe('replacement-token') + }) + + it('records cleanup when new same-key metadata fails after its credential commits', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(saveHost(replacement)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function)) + }) + + it('does not clean a duplicate id that a later same-key save made authoritative', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await saveHost({ ...HOST_ONE, deviceToken: 'token-a' }) + const staleCleanup = scheduledCleanup(duplicate.id) + + await saveHost({ ...duplicate, deviceToken: 'token-b' }) + await staleCleanup(duplicate.id) + + expect(JSON.parse(storedHostsRaw)).toEqual([duplicate]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('keeps stale cleanup pending when a replacement relay write fails', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed')) + + await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow( + 'keychain write failed' + ) + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('stops stale cleanup when a replacement relay write starts during token deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let releaseTokenDelete: () => void = () => {} + secureStoreMock.deleteItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenDelete = resolve + }) + ) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => { + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + releaseTokenDelete() + await bundleWrite + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.anything() + ) + }) + + it('rechecks the write generation immediately before starting credential deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let resolveCleanupRead: (raw: string) => void = () => {} + const cleanupRead = new Promise((resolve) => { + resolveCleanupRead = resolve + }) + let cleanupReadStarted = false + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) { + cleanupReadStarted = true + return cleanupRead + } + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } + return null + }) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => expect(cleanupReadStarted).toBe(true)) + let bundleWrite: Promise | null = null + resolveCleanupRead(storedHostsRaw) + queueMicrotask(() => { + bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + }) + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + await vi.waitFor(() => expect(bundleWrite).not.toBeNull()) + await bundleWrite + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('clears stale relay state when an existing host is re-paired direct-only', async () => { const overlay: MobileRelayHostOverlay = { v: 2, @@ -209,7 +450,63 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) - it('merges v2 endpoints only onto an existing legacy base host', async () => { + it('cancels write-ahead cleanup when host metadata removal fails', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id) + }) + + it('does not cancel cleanup from a removal committed during a stalled save', async () => { + let releaseTokenWrite: () => void = () => {} + secureStoreMock.setItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenWrite = resolve + }) + ) + + const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce()) + await removeHost(HOST_ONE.id) + releaseTokenWrite() + await save + await loadHostCatalog() + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) + expect(cancelCleanupMock).not.toHaveBeenCalled() + }) + + it('refuses an early same-key token write without durable cleanup intent', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable')) + + await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes an unpaired credential when cleanup runs without a newer write', async () => { + await removeHost(HOST_ONE.id) + const cleanup = scheduledCleanup(HOST_ONE.id) + + await cleanup(HOST_ONE.id) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + + it('keeps a concurrently re-published orphan overlay authoritative', async () => { const overlay: MobileRelayHostOverlay = { v: 2, hostId: HOST_ONE.id, @@ -232,8 +529,26 @@ describe('host-store list mutations', () => { } } storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + let releaseCleanup: () => void = () => {} + scheduleCleanupMock.mockReturnValueOnce( + new Promise((resolve) => { + releaseCleanup = resolve + }) + ) - const hosts = await loadHosts() + const hostsLoad = loadHosts() + await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled()) + await saveHost({ + ...HOST_ONE, + id: 'removed-by-old-build', + publicKeyB64: 'restored-key', + deviceToken: 'restored-token', + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + releaseCleanup() + const hosts = await hostsLoad expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ endpoints: overlay.endpoints, @@ -241,6 +556,10 @@ describe('host-store list mutations', () => { relay: overlay.relay }) expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + expect(JSON.parse(storedOverlayRaw!)).toContainEqual({ + ...overlay, + hostId: 'removed-by-old-build' + }) }) it('refuses to resurrect a removed host during relay upgrade publication', async () => { @@ -270,7 +589,9 @@ describe('host-store list mutations', () => { expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) }) expect(settled).toBe(false) - expect(scheduleCleanupMock).toHaveBeenCalledOnce() + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) resolveSchedule?.() await removal @@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () => resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = '[]' storedGenerationRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 43657fffe76..99421ffd192 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,20 +1,13 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Platform } from 'react-native' -import { - HostProfileSchema, - StoredHostProfileSchema, - type HostProfile, - type StoredHostProfile -} from './types' +import { HostProfileSchema } from './types' +import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' import * as hostListLoads from './host-list-load-sharing' +import { joinHostCatalogCredentials } from './host-catalog-credential-join' +import { resetPairingKeychainForTests } from './pairing-keychain' +import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store' import { - deletePairingKeychainItem, - readPairingKeychainItem, - resetPairingKeychainForTests, - writePairingKeychainItem -} from './pairing-keychain' -import { + cancelPendingHostCredentialCleanup, + recordHostCredentialCleanupIntent, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' @@ -24,137 +17,67 @@ import { removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' -import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' +import { + getHostCredentialWriteRevision, + markHostCredentialWrite, + resetHostCredentialWriteRevisionsForTests +} from './host-credential-write-revision' +import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion' +import { + loadStoredHostProfiles, + readStoredHostProfilesForMutation, + toStoredHostProfile, + writeStoredHostProfiles +} from './host-metadata-store' -const STORAGE_KEY = 'orca:hosts' -// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. -const TOKEN_KEY_PREFIX = 'orca.host-token.' -const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' - -function tokenKey(hostId: string): string { - return `${TOKEN_KEY_PREFIX}${hostId}` -} - -function webTokenKey(hostId: string): string { - return `${WEB_TOKEN_KEY_PREFIX}${hostId}` -} - -async function readDeviceToken(hostId: string): Promise { - // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - if (Platform.OS === 'web') { - return AsyncStorage.getItem(webTokenKey(hostId)) - } - return readPairingKeychainItem(tokenKey(hostId)) -} - -async function writeDeviceToken(hostId: string, token: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.setItem(webTokenKey(hostId), token) - return - } - await writePairingKeychainItem(tokenKey(hostId), token) -} - -async function deleteDeviceToken(hostId: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.removeItem(webTokenKey(hostId)) - return - } - await deletePairingKeychainItem(tokenKey(hostId)) -} - -async function deleteHostCredentials(hostId: string): Promise { - await deleteDeviceToken(hostId) - await deleteMobileRelayCredentialBundle(hostId) - await deleteMobileRelayDirectUpgradeJournal(hostId) +async function commitDeviceToken(hostId: string, token: string): Promise { + markHostCredentialWrite(hostId) + await writeHostDeviceToken(hostId, token) + tokenCache.set(hostId, token) + hostListLoads.dropSharedHostListLoad() } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. const tokenCache = new Map() -// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). +// Why: serialize host metadata RMW so concurrent writers cannot drop updates. let hostListMutation: Promise = Promise.resolve() -function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] - } - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return null - } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null - } -} +export const loadHosts = async (): Promise => (await loadHostListSnapshot()).profiles +export const loadHostCatalog = async (): Promise => + (await loadHostListSnapshot()).catalog -export async function loadHosts(): Promise { +async function loadHostListSnapshot(): Promise { // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. - return hostListLoads.shareHostListLoad(doLoadHosts) + return hostListLoads.shareHostListLoad(doLoadHostListSnapshot) } -async function doLoadHosts(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseStoredHosts(raw) +async function doLoadHostListSnapshot(): Promise { + const storedHosts = await loadStoredHostProfiles() if (!storedHosts) { - return [] + return { catalog: [], profiles: [] } } const overlayState = await loadMobileRelayHostOverlayState( new Set(storedHosts.map(({ id }) => id)) ) + const orphanWriteRevisions = new Map( + overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)]) + ) await scheduleOrphanedMobileRelayCleanup({ hostIds: overlayState.orphanHostIds, - deleteCredential: deleteHostCredentials + deleteCredential: (hostId) => + deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0), + removeOverlay: removeOrphanOverlayIfUnpaired + }) + return joinHostCatalogCredentials({ + storedHosts, + overlays: overlayState.overlays, + tokenCache, + readToken: readHostDeviceToken, + getRevision: hostListLoads.getHostListLoadRevision }) - const overlays = overlayState.overlays - - const out: HostProfile[] = [] - for (const stored of storedHosts) { - let token = tokenCache.get(stored.id) - if (!token) { - const readRevision = hostListLoads.getHostListLoadRevision() - let fetched: string | null - try { - fetched = await readDeviceToken(stored.id) - } catch { - // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. - continue - } - if (!fetched) { - // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. - continue - } - token = fetched - if (readRevision === hostListLoads.getHostListLoadRevision()) { - tokenCache.set(stored.id, token) - } - } - const overlay = overlays.get(stored.id) - out.push({ - ...stored, - deviceToken: token, - ...(overlay - ? { - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - } - : {}) - }) - } - return out } export async function resolvePairingHostIdentity( @@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity( ): Promise<{ id: string; name: string }> { // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards. await hostListMutation - const hosts = await readStoredHostsForMutation() + const hosts = await readStoredHostProfilesForMutation() const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) return match ? { id: match.id, name: match.name } : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } -async function readStoredHostsForMutation(): Promise { - try { - const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) - if (!parsed) { - // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. - throw new Error('host list storage unreadable') - } - return parsed - } catch (error) { - if (error instanceof Error && error.message === 'host list storage unreadable') { - throw error - } - throw new Error('host list storage unreadable') +const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({ + waitForHostMutations: () => hostListMutation, + hasStoredHost: async (hostId) => + (await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId), + onDeleted: (hostId) => { + tokenCache.delete(hostId) + hostListLoads.dropSharedHostListLoad() } +}) + +function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise { + const writeRevision = getHostCredentialWriteRevision(hostId) + return scheduleHostCredentialCleanup(hostId, (id) => + deleteUnpairedHostCredentials(id, writeRevision) + ) } -async function mutateStoredHosts( - update: (hosts: StoredHostProfile[]) => StoredHostProfile[] -): Promise { - const mutation = hostListMutation.then(async () => { - const current = await readStoredHostsForMutation() - const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - hostListLoads.dropSharedHostListLoad() +function cancelCleanupForStoredHost(hostId: string): void { + const cancellation = hostListMutation.then(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (hosts.some(({ id }) => id === hostId)) { + // Register before later removals enqueue their intent, without blocking host loads on cleanup storage. + void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined) + } }) + hostListMutation = cancellation.catch(() => {}) +} + +async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable): Promise { + const targets = [...hostIds] + return enqueueHostListMutation(async () => { + const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id)) + await Promise.all( + targets + .filter((hostId) => storedIds.has(hostId)) + .map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)) + ) + }).catch(() => undefined) +} + +function enqueueHostListMutation(operation: () => Promise): Promise { + const mutation = hostListMutation.then(operation) hostListMutation = mutation.catch(() => {}) return mutation } -function toStored(host: HostProfile): StoredHostProfile { - return { - id: host.id, - name: host.name, - endpoint: host.endpoint, - publicKeyB64: host.publicKeyB64, - lastConnected: host.lastConnected - } +function removeOrphanOverlayIfUnpaired(hostId: string): Promise { + return enqueueHostListMutation(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (!hosts.some(({ id }) => id === hostId)) { + await removeMobileRelayHostOverlay(hostId) + } + }) +} + +async function mutateStoredHosts( + update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise +): Promise { + return enqueueHostListMutation(async () => { + const current = await readStoredHostProfilesForMutation() + const next = await update(current) + await writeStoredHostProfiles(next) + hostListLoads.dropSharedHostListLoad() + }) } export class MobileRelayUpgradeHostRemovedError extends Error {} -export async function saveHost(host: HostProfile): Promise { - await persistHost(host, false) -} +export const saveHost = (host: HostProfile): Promise => persistHost(host, false) -export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { - await persistHost(host, true) -} +export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise => + persistHost(host, true) async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) - const stored = toStored(validated) + const stored = toStoredHostProfile(validated) const duplicateHostIds = new Set() let updatedExistingHost = false - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === stored.id) - for (const candidate of hosts) { - if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { - duplicateHostIds.add(candidate.id) + let cleanupIntentRecordedBeforeMetadata = false + let tokenCommittedBeforeMetadata = false + try { + await mutateStoredHosts(async (hosts) => { + const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } + let next: StoredHostProfile[] + if (index >= 0) { + updatedExistingHost = true + // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. + next = hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) + } else if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } else { + next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] + } + if (duplicateHostIds.size > 0) { + if (index < 0) { + // Why: process death between the early token write and metadata publication must leave cleanup discoverable. + await recordHostCredentialCleanupIntent(stored.id) + cleanupIntentRecordedBeforeMetadata = true + } + for (const duplicateHostId of duplicateHostIds) { + await recordHostCredentialCleanupIntent(duplicateHostId) + } + // Why: never remove the only usable same-key row until its replacement credential is durable. + await commitDeviceToken(stored.id, validated.deviceToken) + tokenCommittedBeforeMetadata = true + } + return next + }) + } catch (error) { + await cancelCleanupForDurablyStoredHosts(duplicateHostIds) + if (cleanupIntentRecordedBeforeMetadata) { + try { + await scheduleUnpairedHostCredentialCleanup(stored.id) + } catch { + // The write-ahead cleanup intent remains available for retry. } } - if (index >= 0) { - updatedExistingHost = true - // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. - return hosts - .filter(({ id }) => !duplicateHostIds.has(id)) - .map((candidate) => (candidate.id === stored.id ? stored : candidate)) - } - if (requireExisting) { - // Why: an in-flight relay upgrade must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') - } - return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] - }) - // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. - await writeDeviceToken(stored.id, validated.deviceToken) - tokenCache.set(stored.id, validated.deviceToken) - hostListLoads.dropSharedHostListLoad() + throw error + } + if (!tokenCommittedBeforeMetadata) { + // Why: the catalog can now surface a failed token write for recovery instead of losing the host. + await commitDeviceToken(stored.id, validated.deviceToken) + } + // Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative. + cancelCleanupForStoredHost(stored.id) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, @@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise hostListLoads.dropSharedHostListLoad() } for (const duplicateHostId of duplicateHostIds) { - tokenCache.delete(duplicateHostId) try { - await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(duplicateHostId) } catch { // Metadata is already deduplicated; orphan-token recovery is best-effort. } @@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } export async function removeHost(hostId: string): Promise { - await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) + let cleanupIntentRecorded = false + try { + await mutateStoredHosts(async (hosts) => { + try { + await recordHostCredentialCleanupIntent(hostId) + cleanupIntentRecorded = true + } catch { + // Removal remains authoritative when cleanup intent storage is unavailable. + } + return hosts.filter((h) => h.id !== hostId) + }) + } catch (error) { + if (cleanupIntentRecorded) { + await cancelCleanupForDurablyStoredHosts([hostId]) + } + throw error + } tokenCache.delete(hostId) try { await removeMobileRelayHostOverlay(hostId) @@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise { } // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(hostId) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteHostCredentials) + return retryPendingHostCredentialCleanups((hostId) => + deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId)) + ) } // Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other. @@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise { export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() + resetHostCredentialWriteRevisionsForTests() hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index a08fa052459..089c6d28c56 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -7,6 +7,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) + markHostCredentialWrite(validated.hostId) await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 2493c9e78af..e21e75a6b61 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -6,6 +6,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + markHostCredentialWrite(parsed.hostId) await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 400deee90a6..9d96f0561ad 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -75,6 +75,13 @@ export type HostProfile = { relay?: MobileRelayHostOverlay['relay'] } +export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing' + +export type HostCatalogEntry = Omit & { + credentialStatus: HostCredentialStatus + profile: HostProfile | null +} + export const HostProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), diff --git a/mobile/src/transport/unpaired-host-credential-deletion.ts b/mobile/src/transport/unpaired-host-credential-deletion.ts new file mode 100644 index 00000000000..cc9c27e49ad --- /dev/null +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -0,0 +1,58 @@ +import { deleteHostDeviceToken } from './host-device-token-store' +import { + clearHostCredentialWriteRevision, + getHostCredentialWriteRevision +} from './host-credential-write-revision' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' + +type DeletionDependencies = { + waitForHostMutations: () => Promise + hasStoredHost: (hostId: string) => Promise + onDeleted: (hostId: string) => void +} + +export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) { + function writeRevisionChanged(hostId: string, writeRevision: number): boolean { + return getHostCredentialWriteRevision(hostId) !== writeRevision + } + + function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void { + if (writeRevisionChanged(hostId, writeRevision)) { + throw new Error('credential write superseded cleanup') + } + } + + async function shouldSkip(hostId: string, writeRevision: number): Promise { + await dependencies.waitForHostMutations() + if (await dependencies.hasStoredHost(hostId)) { + return true + } + assertWriteRevisionUnchanged(hostId, writeRevision) + return false + } + + return async (hostId: string, writeRevision: number): Promise => { + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteHostDeviceToken(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayCredentialBundle(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayDirectUpgradeJournal(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + clearHostCredentialWriteRevision(hostId) + dependencies.onDeleted(hostId) + } +} From 7da9368b786ab5ca5f78a4f1e0c03019a4f15882 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:12:32 -0700 Subject: [PATCH 26/88] fix(terminal): fence detached daemon endpoint ownership (#12709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): fence daemon endpoint ownership * fix(terminal): clean failed daemon PID claims * fix(terminal): close daemon ownership review gaps * test(daemon): release startup IPC in boot smoke * test(daemon): mirror production stdio in boot smoke * fix(daemon): exit after rpc shutdown cleanup * fix(terminal): make the socket name the daemon endpoint authority The reported failure was a live daemon hosting PTYs that nothing could reach: terminals acknowledged input and never ran it, listings diverged from reality, and restarting the app never helped because the detached helper survived. The ownership fence added for it could not fire in the sequence that produces the split brain. libuv unlinks the pathname a server bound to when that server closes, with no ownership check. A daemon that lost its endpoint name therefore deleted whichever socket then sat at that path — including a live replacement's — stranding a daemon that still hosted every session. Bind a private same-directory name and hard-link it into place instead: libuv can only ever unlink our own bind name, the exclusive link is a kernel-enforced endpoint claim, and the canonical name is removed only under an inode ownership check. The bind name replaces the basename rather than extending it, so it cannot overflow sun_path. killStaleDaemon removed the PID record unconditionally immediately before every fork, so the exclusive PID claim was always uncontested at bind time. It also unlinked a live daemon's endpoint whenever a connect probe merely timed out, and treated a `ps` timeout as proof of PID recycling. Now only positive evidence of a dead endpoint authorizes reclaiming it, SIGKILL is confirmed rather than assumed, and a daemon that cannot be proven stopped keeps its record and endpoint while the launcher refuses to fork beside it. A daemon whose endpoint was taken over now retires itself, draining rather than killing, so an unreachable orphan stops being permanent. A repaired PID record re-derives entryPath, appVersion and the Linux incarnation markers from the authenticated owner instead of dropping them; without appVersion a healthy daemon read as a permanently stale bundle and, on Windows, went unpinned against daemon-host pruning. Repair failure now fails open — abandoning a healthy daemon over a pid file write cost every persistent terminal on the machine. Also: treat only ENOENT as an unclaimed record so a Windows file lock is not reported as an ownership conflict; settle start() before close() so an accepted connection cannot defer it forever; sweep abandoned claim and bind names; and type the endpoint-identity seam so a rename cannot silently disable the fence. Adds a real-process handover smoke that reproduces the failure with two daemons racing one endpoint, and wires it into the native-smoke job. * fix(daemon): retire only on proven endpoint ownership loss The ownership watchdog read a null identity for any stat failure, so a transient EACCES or EIO on the runtime directory would retire a daemon that was still serving every terminal on the machine. Distinguish "the entry is gone" from "the probe failed" and act only on the former. Also require the loss to persist across two polls: a replacement publishes by unlink-then-link, and a single observation can land in that gap. * fix(daemon): source repaired ownership metadata from the authenticated hello Adversarial review found three defects in the previous two commits. Re-deriving entryPath from the owner's command line truncated it at the first space. A command line is a single space-joined string, so `C:\Program Files\Orca\...` and `/Applications/Orca 2.app/...` came back as `"C:\Program` and `/Applications/Orca`. getDaemonLaunchIdentity treats a present entryPath as authoritative, so a healthy daemon read as `different_app_path` and was killed and re-forked — worse than the missing-metadata case the derivation was added to fix. Carry entryPath and appVersion as optional fields on the daemon hello identity instead: the daemon already has both from its own argv, and per docs/reference/remote-wire-compatibility.md a new optional field is safe because every reader falls back when it is absent. This also removes a synchronous `ps` spawn from the Electron main thread during startup. `start()` rolled back the PID record even when it never published one. Losing the endpoint link now runs that path, and the ownership-checked unlink briefly renames the incumbent's record aside — enough to strand a live daemon's ownership. Roll back only what we actually wrote. publishDaemonSocketPath read its identity from the canonical name after linking, so a concurrent unlink returned null: no ownership watchdog and no endpoint cleanup on any shutdown path. Read it from the bound name before linking, which shares the inode. Refusing to fork beside an unconfirmed daemon left the user with no daemon at all and no in-app recovery, since restart re-entered the same fence. We have just proved something answers the endpoint, so adopt it in degraded mode: live sessions keep working, fresh terminals run locally. SIGTERM is also individually guarded now — an EPERM fell into the blanket catch and reported "nothing alive", authorizing the very duplicate this fence exists to prevent. Also reset the ownership-loss streak on an inconclusive probe so the confirmations are consecutive, and sweep scratch names before the launch so a failed launch still reclaims them. --- .github/workflows/computer-e2e.yml | 6 + config/scripts/daemon-boot-smoke.mjs | 140 +++-- .../daemon-endpoint-handover-smoke.mjs | 164 ++++++ src/main/daemon/client.ts | 16 +- .../daemon/daemon-endpoint-ownership.test.ts | 271 ++++++++++ src/main/daemon/daemon-endpoint-ownership.ts | 169 ++++++ src/main/daemon/daemon-entry.test.ts | 24 + src/main/daemon/daemon-entry.ts | 48 +- .../daemon-health-socket-cleanup.test.ts | 2 +- src/main/daemon/daemon-health.test.ts | 135 ++++- src/main/daemon/daemon-health.ts | 131 ++++- src/main/daemon/daemon-hello-protocol.ts | 3 + src/main/daemon/daemon-init.test.ts | 510 +++++++++++++++--- src/main/daemon/daemon-init.ts | 212 ++++++-- src/main/daemon/daemon-main.ts | 10 + src/main/daemon/daemon-ready-identity.ts | 21 + src/main/daemon/daemon-server.test.ts | 11 +- src/main/daemon/daemon-server.ts | 202 ++++++- src/main/daemon/daemon-spawner.test.ts | 223 +++++++- src/main/daemon/daemon-spawner.ts | 65 ++- src/main/daemon/production-launcher.test.ts | 94 +++- src/main/daemon/production-launcher.ts | 71 ++- 22 files changed, 2279 insertions(+), 249 deletions(-) create mode 100644 config/scripts/daemon-endpoint-handover-smoke.mjs create mode 100644 src/main/daemon/daemon-endpoint-ownership.test.ts create mode 100644 src/main/daemon/daemon-endpoint-ownership.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 731521ea8d6..f6f2c0bd13c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -20,6 +20,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/daemon-endpoint-handover-smoke.mjs' - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain @@ -135,6 +136,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: a daemon that lost its endpoint name used to delete the replacement's + # socket on exit, stranding a live daemon that hosts PTYs nothing can reach. + # Only two real processes racing the same endpoint reproduce it. + - name: Daemon endpoint handover smoke + run: node config/scripts/daemon-endpoint-handover-smoke.mjs # Why: workspace removal overlaps graceful renderer teardown with the # forced main-process sweep; exercise that exact pair against real ConPTY. - name: Windows daemon workspace-close repro diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index 4ed155e8c5f..22c78a21cc2 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -18,7 +18,7 @@ import { fork } from 'node:child_process' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) { return join(userDataDir, 'daemon.sock') } -// Best-effort end-to-end PTY check over the daemon's own control-socket RPC. -// Connects a single control socket, completes the hello handshake, and calls -// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves -// true on success, false on any failure — never throws. -function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { - return new Promise((resolveCheck) => { +function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) { + return new Promise((resolveRpc, rejectRpc) => { let settled = false let buffer = '' const socket = connect(socketPath) - const finish = (ok, reason) => { + const finish = (error, response) => { if (settled) { return } settled = true clearTimeout(timer) socket.destroy() - if (!ok && reason) { - log(`PTY spawn health check skipped (best-effort): ${reason}`) + if (error) { + rejectRpc(error) + } else { + resolveRpc(response) } - resolveCheck(ok) } - const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS) + const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs) - socket.on('error', (err) => finish(false, err.message)) + socket.on('error', (error) => finish(error)) socket.on('connect', () => { const token = readFileSync(tokenPath, 'utf8').trim() socket.write( @@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { try { msg = JSON.parse(line) } catch { - finish(false, 'invalid response line') + finish(new Error('invalid response line')) return } if (msg.type === 'hello') { if (!msg.ok) { - finish(false, `hello rejected: ${msg.error ?? 'unknown'}`) + finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`)) return } - socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`) - } else if (msg.id === 'health-1') { + socket.write(`${JSON.stringify(request)}\n`) + } else if (msg.id === request.id) { finish( - msg.ok === true, - msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed') + msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`), + msg ) return } @@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { }) } +// Best-effort: constrained CI runners can make node-pty spawn flaky. +async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { + try { + await runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { id: 'health-1', type: 'ptySpawnHealth' }, + PTY_HEALTH_TIMEOUT_MS + ) + return true + } catch (error) { + log(`PTY spawn health check skipped (best-effort): ${error.message}`) + return false + } +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-')) const socketPath = makeSocketPath(userDataDir) const tokenPath = join(userDataDir, 'daemon.token') + const pidPath = join(userDataDir, 'daemon.pid') + const launchNonce = randomUUID() const protocolVersion = readProtocolVersion() log(`forking ${entryPath} under plain Node (${process.execPath})`) - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in - // CI, and this is exactly the runtime where a leaked `require("electron")` - // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + 'daemon-boot-smoke' + ], + { + // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in + // CI, and this is exactly the runtime where a leaked `require("electron")` + // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } + } + ) let stderr = '' child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') }) - child.stdout?.on('data', (chunk) => { - process.stdout.write(chunk) - }) const cleanup = () => { if (child.exitCode === null && child.signalCode === null && child.pid) { @@ -185,6 +215,25 @@ async function main() { }) }) log('daemon signaled ready') + const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8')) + if ( + pidRecord.pid !== child.pid || + pidRecord.launchNonce !== launchNonce || + pidRecord.entryPath !== entryPath || + pidRecord.appVersion !== 'daemon-boot-smoke' + ) { + throw new Error('daemon readiness did not publish the expected PID ownership record') + } + log('PID ownership record matches the ready daemon') + if (!existsSync(socketPath)) { + throw new Error('daemon did not publish its endpoint at the canonical socket path') + } + log('endpoint published at the canonical socket path') + // Production releases startup-only handles after ready; they can pin the child on Windows. + // Diagnostics past this point therefore carry the tail captured up to readiness only. + child.stderr?.destroy() + stderr += '[boot-smoke] stderr released at readiness, mirroring production\n' + child.disconnect() const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) if (ptyHealthy) { @@ -193,18 +242,39 @@ async function main() { await new Promise((resolveExit, rejectExit) => { const timer = setTimeout(() => { - rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)) + rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`)) }, SHUTDOWN_TIMEOUT_MS) child.on('exit', (code, signal) => { clearTimeout(timer) - log(`daemon exited after signal (code=${code}, signal=${signal})`) + log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`) resolveExit() }) - // Why: SIGTERM is the graceful stop on POSIX (the daemon handles it); - // Windows has no POSIX signal delivery, so Node maps this to process - // termination. Either way the hard assertion is "it stops, no hang". - child.kill('SIGTERM') + void runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { + id: 'shutdown-1', + type: 'shutdown', + payload: { killSessions: false } + }, + SHUTDOWN_TIMEOUT_MS + ).catch((error) => { + clearTimeout(timer) + rejectExit(error) + }) }) + if (existsSync(pidPath)) { + throw new Error('daemon left its PID ownership record behind after shutdown') + } + if (process.platform !== 'win32' && existsSync(socketPath)) { + throw new Error('daemon left its endpoint socket behind after shutdown') + } + // The private bind name is consumed by the publish; nothing may linger in the runtime dir. + const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b')) + if (leaked.length > 0) { + throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`) + } log('PASS: daemon booted, served, and shut down under plain Node') } finally { diff --git a/config/scripts/daemon-endpoint-handover-smoke.mjs b/config/scripts/daemon-endpoint-handover-smoke.mjs new file mode 100644 index 00000000000..b6c39a5b55f --- /dev/null +++ b/config/scripts/daemon-endpoint-handover-smoke.mjs @@ -0,0 +1,164 @@ +/** + * Endpoint handover smoke — guards the split-brain failure with real daemon processes. + * + * The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still + * alive used to delete the *replacement's* socket when it finally exited, because libuv + * unlinks the pathname a server bound to with no ownership check. The replacement stayed + * alive hosting PTYs that nothing could reach — terminals that acknowledge input and never + * run it, and that a user cannot fix by restarting the app. + * + * Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur. + * + * Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs + */ +import { fork } from 'node:child_process' +import { connect } from 'node:net' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js') +const READY_TIMEOUT_MS = 20_000 +const EXIT_TIMEOUT_MS = 15_000 + +const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`) + +function bootDaemon(tag, dir, socketPath) { + const tokenPath = join(dir, `${tag}.token`) + const pidPath = join(dir, `${tag}.pid`) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + randomUUID(), + '--entry-path', + entryPath, + '--app-version', + 'endpoint-handover-smoke' + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: dir } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (msg) => { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + clearTimeout(timer) + resolveReady({ child, tokenPath, pidPath }) + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`)) + }) + }) +} + +function isReachable(socketPath) { + return new Promise((resolveReachable) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolveReachable(true) + }) + socket.on('error', () => resolveReachable(false)) + }) +} + +function killAndWait(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit, rejectExit) => { + const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS) + child.on('exit', () => { + clearTimeout(timer) + resolveExit() + }) + child.kill('SIGTERM') + }) +} + +async function main() { + if (process.platform === 'win32') { + log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur') + return + } + if (!existsSync(entryPath)) { + throw new Error(`missing ${entryPath} — run \`pnpm build\` first`) + } + + const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-')) + const socketPath = join(dir, 'daemon.sock') + let replaced + let replacement + try { + replaced = await bootDaemon('replaced', dir, socketPath) + const replacedInode = statSync(socketPath).ino + log('daemon A published the endpoint') + + // Reclaim the endpoint name the way daemon replacement does, while A is still alive. + unlinkSync(socketPath) + replacement = await bootDaemon('replacement', dir, socketPath) + const replacementInode = statSync(socketPath).ino + if (replacedInode === replacementInode) { + throw new Error('daemon B did not publish a distinct endpoint') + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B is not reachable through the canonical endpoint') + } + log('daemon B took over the endpoint and is reachable') + + // A exits long after losing the endpoint. This is the step that used to break B. + await killAndWait(replaced.child) + await new Promise((r) => setTimeout(r, 300)) + + if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) { + throw new Error("daemon A's late exit deleted daemon B's endpoint") + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B became unreachable after daemon A exited') + } + if (!existsSync(replacement.pidPath)) { + throw new Error("daemon A's late exit removed daemon B's ownership record") + } + if (existsSync(replaced.pidPath)) { + throw new Error('daemon A left its own ownership record behind') + } + + log('PASS: the endpoint owner and the session host stayed the same daemon') + } finally { + for (const daemon of [replaced, replacement]) { + if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) { + try { + daemon.child.kill('SIGKILL') + } catch { + // already gone + } + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`) + process.exit(1) +}) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index dd1d9066a02..a43ca8d05e4 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -509,7 +509,13 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n if (!value || typeof value !== 'object') { return null } - const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown } + const identity = value as { + pid?: unknown + startedAtMs?: unknown + launchNonce?: unknown + entryPath?: unknown + appVersion?: unknown + } if ( !Number.isSafeInteger(identity.pid) || (identity.pid as number) <= 0 || @@ -524,7 +530,13 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n return { pid: identity.pid as number, startedAtMs: identity.startedAtMs, - launchNonce: identity.launchNonce + launchNonce: identity.launchNonce, + ...(typeof identity.entryPath === 'string' && identity.entryPath.length > 0 + ? { entryPath: identity.entryPath } + : {}), + ...(typeof identity.appVersion === 'string' && identity.appVersion.length > 0 + ? { appVersion: identity.appVersion } + : {}) } } diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts new file mode 100644 index 00000000000..8f4e4b82f55 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { connect, createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath, publishDaemonPidFile } from './daemon-spawner' +import { readDaemonSocketIdentity } from './daemon-endpoint-ownership' +import type { SubprocessHandle } from './session' + +function connectsTo(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolve(true) + }) + socket.on('error', () => resolve(false)) + }) +} + +function createMockSubprocess(): SubprocessHandle { + return { + pid: 55555, + getForegroundProcess: () => null, + write() {}, + resize() {}, + kill() {}, + forceKill() {}, + signal() {}, + onData() {}, + onExit() {}, + dispose() {} + } +} + +describe('daemon endpoint ownership publication', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-endpoint-ownership-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('does not publish an adoptable endpoint before durable ownership succeeds', async () => { + writeFileSync(tokenPath, 'previous-token') + const publishEndpointOwnership = vi.fn(() => { + expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token') + throw Object.assign(new Error('PID record already owned'), { + code: 'EEXIST' + }) + }) + server = new DaemonServer({ + socketPath, + tokenPath, + publishEndpointOwnership, + spawnSubprocess: () => createMockSubprocess() + }) + + await expect(server.start()).rejects.toThrow('PID record already owned') + + expect(publishEndpointOwnership).toHaveBeenCalledOnce() + expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token') + if (process.platform !== 'win32') { + expect(existsSync(socketPath)).toBe(false) + } + }) + + it('rolls back exact PID ownership when token publication fails', async () => { + const pidPath = join(dir, 'daemon.pid') + const launchNonce = 'failed-launch' + mkdirSync(tokenPath) + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce, + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce + }), + spawnSubprocess: () => createMockSubprocess() + }) + + await expect(server.start()).rejects.toMatchObject({ code: 'EISDIR' }) + + expect(existsSync(pidPath)).toBe(false) + expect(existsSync(tokenPath)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'keeps a replacement endpoint when the daemon it replaced closes late', + async () => { + // Why: this is the split-brain mechanism. libuv unlinks the pathname a server bound to + // when that server closes, with no ownership check — so a daemon exiting late used to + // delete whichever socket then sat at the canonical path. The replacement stayed alive + // hosting PTYs that no client could reach, which is what "terminals ack but never run" + // looked like from the user's seat. + const replacedPidPath = join(dir, 'replaced.pid') + const replaced = new DaemonServer({ + socketPath, + tokenPath, + pidPath: replacedPidPath, + launchNonce: 'replaced-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(replacedPidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'replaced-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await replaced.start() + + // A replacement reclaims the endpoint the way killStaleDaemon does. + unlinkSync(socketPath) + const pidPath = join(dir, 'replacement.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'replacement-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 2_000, + launchNonce: 'replacement-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + const replacementIdentity = readDaemonSocketIdentity(socketPath) + + // The daemon that lost the endpoint now exits, long after the handover. + await replaced.shutdown() + + expect(existsSync(socketPath)).toBe(true) + expect(readDaemonSocketIdentity(socketPath)).toEqual(replacementIdentity) + // The replacement is still reachable through the canonical name. + await expect(connectsTo(socketPath)).resolves.toBe(true) + // The late exit also must not remove the replacement's ownership record. + expect(existsSync(pidPath)).toBe(true) + } + ) + + it.skipIf(process.platform === 'win32')( + 'retires a daemon whose endpoint was taken over by another daemon', + async () => { + const pidPath = join(dir, 'daemon.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'original-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'original-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + const daemon = server as unknown as { + checkEndpointOwnership: () => void + retirementRequested: boolean + } + // An inconclusive or matching probe must never retire a healthy daemon, however often it runs. + daemon.checkEndpointOwnership() + daemon.checkEndpointOwnership() + expect(daemon.retirementRequested).toBe(false) + + // Another daemon takes the endpoint name. + unlinkSync(socketPath) + const usurper = createServer() + const usurperBind = join(dir, '.usurper') + await new Promise((resolve) => usurper.listen(usurperBind, resolve)) + linkSync(usurperBind, socketPath) + unlinkSync(usurperBind) + + try { + // A single observation can land inside a replacement's unlink-then-link gap, so the + // first one must not retire anything. + daemon.checkEndpointOwnership() + expect(daemon.retirementRequested).toBe(false) + + daemon.checkEndpointOwnership() + // Why: retirement drains rather than kills — an orphaned daemon stops being a + // permanent unreachable host without tearing live sessions out from under the user. + expect(daemon.retirementRequested).toBe(true) + } finally { + await new Promise((resolve) => usurper.close(() => resolve())) + try { + unlinkSync(socketPath) + } catch { + // Already gone. + } + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'refuses a second listener when a live daemon socket path was removed', + async () => { + const pidPath = join(dir, 'daemon.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'first-launch', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'first-launch' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + const firstToken = readFileSync(tokenPath, 'utf8') + unlinkSync(socketPath) + + const duplicate = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'second-launch', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 2_000, + launchNonce: 'second-launch' + }), + spawnSubprocess: () => createMockSubprocess() + }) + try { + await expect(duplicate.start()).rejects.toMatchObject({ + code: 'EEXIST' + }) + expect(readFileSync(tokenPath, 'utf8')).toBe(firstToken) + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + startedAtMs: 1_000, + launchNonce: 'first-launch' + }) + } finally { + await duplicate.shutdown() + } + } + ) +}) diff --git a/src/main/daemon/daemon-endpoint-ownership.ts b/src/main/daemon/daemon-endpoint-ownership.ts new file mode 100644 index 00000000000..31e22844793 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-ownership.ts @@ -0,0 +1,169 @@ +/* Ownership of the daemon's canonical endpoint name and of the scratch entries the + rename-claim protocol leaves behind. Kept apart from daemon-spawner so the rule that + decides who may serve on the socket path is readable on its own. */ +import { randomBytes } from 'node:crypto' +import { existsSync, linkSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** The exact directory entry a daemon owns. Compared before any endpoint removal. */ +export type DaemonSocketIdentity = { dev: bigint; ino: bigint } + +/** + * A private, same-directory name to bind before publishing the canonical endpoint. + * + * Why: `sockaddr_un.sun_path` caps a Unix socket path at ~104 bytes, so this must not extend + * the canonical path — it replaces the basename with a shorter one, which keeps the bind name + * strictly shorter than the endpoint the caller already requires to fit. + */ +export function getDaemonSocketBindPath(socketPath: string): string { + return join(dirname(socketPath), `.b${randomBytes(5).toString('hex')}`) +} + +/** + * Publishes a bound listener under the canonical endpoint name. + * + * Why: Node/libuv unlinks the pathname a server bound to when that server closes, + * with no ownership check — a daemon exiting late therefore deletes whichever socket + * currently sits at that path, including a live replacement's. Binding a unique path + * and hard-linking it into place instead means libuv only ever unlinks the private + * bind name, and the exclusive link doubles as the kernel-enforced endpoint claim. + */ +export function publishDaemonSocketPath( + boundPath: string, + canonicalPath: string +): DaemonSocketIdentity | null { + if (process.platform === 'win32') { + // Named pipes are not directory entries; the pipe name itself is exclusive. + return null + } + // Why: stat the bound name first — the link shares the inode, so a racing unlink of the + // canonical name cannot erase our identity and leave the endpoint unwatched and uncleanable. + const identity = readDaemonSocketIdentity(boundPath) + try { + linkSync(boundPath, canonicalPath) + } catch (error) { + if (isFileExistsError(error)) { + throw error + } + // Why: a filesystem without hard links must not stop the daemon from starting. Rename + // still moves the bind name out from under libuv, which preserves the property that + // matters most — a late close cannot delete a replacement's endpoint. Exclusivity + // degrades to check-then-act here, which is no weaker than binding the path directly. + if (existsSync(canonicalPath)) { + throw error + } + renameSync(boundPath, canonicalPath) + return identity + } + try { + unlinkSync(boundPath) + } catch { + // Inert: clients resolve the canonical link, and the bind name is unique to us. + } + return identity +} + +function isFileExistsError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' +} + +export function readDaemonSocketIdentity(socketPath: string): DaemonSocketIdentity | null { + if (process.platform === 'win32') { + return null + } + try { + const stats = statSync(socketPath, { bigint: true }) + return { dev: stats.dev, ino: stats.ino } + } catch { + return null + } +} + +export function daemonSocketIdentityMatches( + a: DaemonSocketIdentity | null, + b: DaemonSocketIdentity | null +): boolean { + return a !== null && b !== null && a.dev === b.dev && a.ino === b.ino +} + +/** 'indeterminate' is deliberately distinct from 'lost': only positive evidence may retire a daemon. */ +export type DaemonEndpointOwnershipState = 'owned' | 'lost' | 'indeterminate' + +export function readDaemonEndpointOwnershipState( + socketPath: string, + owned: DaemonSocketIdentity | null +): DaemonEndpointOwnershipState { + if (process.platform === 'win32' || !owned) { + return 'indeterminate' + } + try { + const stats = statSync(socketPath, { bigint: true }) + return stats.dev === owned.dev && stats.ino === owned.ino ? 'owned' : 'lost' + } catch (error) { + // Why: a stat that failed for any reason other than "the entry is gone" proves nothing. + // Treating EACCES or EIO as lost ownership would retire a perfectly healthy daemon. + return isMissingFileError(error) ? 'lost' : 'indeterminate' + } +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +/** Removes the canonical endpoint name only while it still resolves to our own listener. */ +export function unlinkOwnedDaemonSocketPath( + socketPath: string, + owned: DaemonSocketIdentity | null +): boolean { + if (process.platform === 'win32' || !owned) { + return false + } + if (!daemonSocketIdentityMatches(readDaemonSocketIdentity(socketPath), owned)) { + return false + } + try { + unlinkSync(socketPath) + return true + } catch { + return false + } +} + +const ABANDONED_DAEMON_CLAIM_PATTERN = + /(?:\.(?:cleanup|replace)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|^\.b[0-9a-f]{10})$/ + +const ABANDONED_DAEMON_CLAIM_MIN_AGE_MS = 60 * 60 * 1000 + +/** + * Reclaims claim/bind scratch names left behind when a rename-claim or bind publish + * could not remove its own temporary entry. Age-gated so a claim in flight is never touched. + */ +export function sweepAbandonedDaemonClaims( + runtimeDir: string, + minAgeMs = ABANDONED_DAEMON_CLAIM_MIN_AGE_MS, + now = Date.now() +): number { + let swept = 0 + let entries: string[] + try { + entries = readdirSync(runtimeDir) + } catch { + return 0 + } + for (const entry of entries) { + if (!ABANDONED_DAEMON_CLAIM_PATTERN.test(entry)) { + continue + } + const claimPath = join(runtimeDir, entry) + try { + if (now - statSync(claimPath).mtimeMs < minAgeMs) { + continue + } + unlinkSync(claimPath) + swept++ + } catch { + // Best-effort; a locked or already-removed claim is retried on a future launch. + } + } + return swept +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index 6b02210d72a..8e5ff598df5 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -71,6 +71,30 @@ describe('daemon-entry parseArgs', () => { }) }) + it('parses PID publication metadata from the launcher', () => { + expect( + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid', + '--launch-nonce', + 'launch-a', + '--entry-path', + '/app/daemon-entry.js', + '--app-version', + '1.2.3' + ]) + ).toMatchObject({ + pidPath: '/tmp/t.pid', + launchNonce: 'launch-a', + entryPath: '/app/daemon-entry.js', + appVersion: '1.2.3' + }) + }) + it('rejects either PID-record ownership argument without its pair', () => { expect(() => parseArgs([ diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index d7655758b67..32628d5275d 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -20,12 +20,15 @@ import { import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' +import { publishDaemonPidFile } from './daemon-spawner' export type ParsedDaemonArgs = { socketPath: string tokenPath: string pidPath?: string launchNonce?: string + entryPath?: string + appVersion?: string /** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */ loginSessionWatch?: boolean /** Optional — absent for adopted old daemons and tests, which log nothing. */ @@ -38,6 +41,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let logFilePath = '' let pidPath = '' let launchNonce = '' + let entryPath = '' + let appVersion = '' let loginSessionWatch = false for (let i = 0; i < argv.length; i++) { @@ -56,6 +61,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { launchNonce = argv[i + 1] i++ + } else if (argv[i] === '--entry-path' && argv[i + 1]) { + entryPath = argv[i + 1] + i++ + } else if (argv[i] === '--app-version' && argv[i + 1]) { + appVersion = argv[i + 1] + i++ } else if (argv[i] === '--login-session-watch') { loginSessionWatch = true } @@ -73,6 +84,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { socketPath, tokenPath, ...(pidPath ? { pidPath, launchNonce } : {}), + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), ...(loginSessionWatch ? { loginSessionWatch } : {}), ...(logFilePath ? { logFilePath } : {}) } @@ -86,10 +99,18 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs( - process.argv.slice(2) - ) + const { + socketPath, + tokenPath, + pidPath, + launchNonce, + entryPath, + appVersion, + loginSessionWatch, + logFilePath + } = parseArgs(process.argv.slice(2)) const startedAtMs = Date.now() - process.uptime() * 1000 + const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) // Fail-open: a broken log path must never block daemon startup. const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) @@ -236,6 +257,20 @@ async function main(): Promise { ...(pidPath ? { pidPath } : {}), ...(launchNonce ? { launchNonce } : {}), ...(pidPath ? { startedAtMs } : {}), + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), + ...(pidPath && launchNonce + ? { + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + ...readyIdentity, + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), + launchNonce + }) + } + : {}), log: daemonLog, preparePtySpawn: runMacosLoginPreflight, ...(deathWatch @@ -260,13 +295,18 @@ async function main(): Promise { daemonLog.log('shutdown', { reason: 'idle' }) daemonLog.close() process.exit(0) + }, + onRpcShutdown: () => { + deathWatch?.stop() + shuttingDown = true + daemonLog.close() + process.exit(0) } }) deathWatch?.start() // Signal readiness to parent via IPC (if available) if (process.send) { - const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) process.send({ type: 'ready', ...readyIdentity }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index 6daa6cdf00b..07294410889 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -79,7 +79,7 @@ describe('daemon health socket listener cleanup', () => { const result = killStaleDaemon(dir, socketPath, tokenPath) await vi.advanceTimersByTimeAsync(500) - await expect(result).resolves.toBe(false) + await expect(result).resolves.toEqual({ killed: false, liveOwnerSurvived: false }) expect(socket.listenerCount('connect')).toBe(0) expect(socket.listenerCount('error')).toBe(0) expect(socket.destroy).toHaveBeenCalledTimes(1) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index ccfb7d1f0e9..fe459a875c5 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { existsSync, linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import { createServer, connect, type Server } from 'node:net' +import { createServer, connect, Socket, type Server } from 'node:net' import { DaemonServer } from './daemon-server' import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import { @@ -183,7 +184,9 @@ describe('daemon health', () => { writeFileSync(getDaemonPidPath(dir), String(process.pid), { mode: 0o600 }) try { - await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false) + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({ + killed: false + }) await expect(canConnect(socketPath)).resolves.toBe(true) } finally { await closeServer(server) @@ -430,7 +433,9 @@ describe('killStaleDaemon pid identity guards', () => { // signal is sent. const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) try { - await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false) + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({ + killed: false + }) const terminationSignals = killSpy.mock.calls.filter( ([, sig]) => sig === 'SIGTERM' || sig === 'SIGKILL' ) @@ -440,3 +445,125 @@ describe('killStaleDaemon pid identity guards', () => { } }) }) + +describe('killStaleDaemon endpoint reclamation', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-health-kill-test-')) + socketPath = daemonTestSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function listenOnSocketPath(server: Server, path: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(path, () => { + server.off('error', reject) + resolve() + }) + }) + } + + it('preserves the pid record and the endpoint when the owner cannot be proven gone', async () => { + if (process.platform === 'win32') { + return + } + + // Why: isDaemonProcess matches on the command line, so a child carrying the daemon + // entry plus this endpoint's paths is adopted as the recorded owner by the first probe. + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)', 'daemon-entry', socketPath, tokenPath], + { stdio: 'ignore' } + ) + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) + const childPid = child.pid as number + const childExited = new Promise((resolve) => child.once('exit', () => resolve())) + const server = createServer((socket) => socket.end()) + await listenOnSocketPath(server, socketPath) + writeFileSync( + getDaemonPidPath(dir), + serializeDaemonPidFile({ pid: childPid, startedAtMs: null }), + { mode: 0o600 } + ) + + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + // Why: the recorded owner vanishes mid-wait, so the pre-SIGKILL identity re-probe + // fails and only the still-answering endpoint proves a daemon is alive. + const identityBreaker = setTimeout(() => realKill(childPid, 'SIGKILL'), 800) + try { + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: true + }) + expect(existsSync(getDaemonPidPath(dir))).toBe(true) + expect(existsSync(socketPath)).toBe(true) + await expect(canConnect(socketPath)).resolves.toBe(true) + } finally { + clearTimeout(identityBreaker) + killSpy.mockRestore() + try { + realKill(childPid, 'SIGKILL') + } catch { + // Already gone. + } + await childExited + await closeServer(server) + } + }) + + it('unlinks a stale endpoint file once connects to it are refused', async () => { + if (process.platform === 'win32') { + return + } + + // Why: hard-linking the bound inode leaves the endpoint name behind after the + // listener closes — exactly the entry a crashed daemon leaves for connect to refuse. + const bindPath = join(dir, '.bstale') + const server = createServer((socket) => socket.end()) + await listenOnSocketPath(server, bindPath) + linkSync(bindPath, socketPath) + await closeServer(server) + + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: false + }) + expect(existsSync(socketPath)).toBe(false) + }) + + it('keeps the endpoint file when the connect probe neither connects nor is refused', async () => { + if (process.platform === 'win32') { + return + } + + writeFileSync(socketPath, '') + // A connect that never settles leaves the probe to time out, which is not proof + // the endpoint is dead — net.connect itself cannot be spied on in ESM. + const connectSpy = vi + .spyOn(Socket.prototype, 'connect') + .mockImplementation(function (this: Socket) { + return this + }) + try { + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: false + }) + expect(existsSync(socketPath)).toBe(true) + } finally { + connectSpy.mockRestore() + } + }) +}) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 71bc7f3279b..952b0c3688b 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -23,6 +23,9 @@ const HEALTH_CHECK_TIMEOUT_MS = 3_000 const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 +// Why: SIGKILL is delivered on return from an uninterruptible syscall, so confirm the exit +// rather than assume it — but keep the wait short, it only guards the rare wedged case. +const SIGKILL_CONFIRM_WAIT_MS = 1_000 const START_TIME_TOLERANCE_MS = 1_500 // Why: e2e forces the failed-health preserve path without SIGSTOP races — // a stopped daemon also blocks listSessions, so the unhealthy guard cannot @@ -50,10 +53,17 @@ export type ParsedDaemonPid = { bootId: string | null } -function canConnectSocket(socketPath: string): Promise { +/** + * 'connected' — something is listening. 'missing'/'refused' — nothing is, and the endpoint + * name is safe to reclaim. 'unknown' — the probe itself failed (timeout on a loaded host, + * EPERM); the endpoint must be left alone because absence of proof is not proof of death. + */ +type SocketProbeOutcome = 'connected' | 'missing' | 'refused' | 'unknown' + +function probeSocketConnect(socketPath: string): Promise { return new Promise((resolve) => { if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolve(false) + resolve('missing') return } const sock = connect({ path: socketPath }) @@ -63,7 +73,7 @@ function canConnectSocket(socketPath: string): Promise { sock.off('connect', onConnect) sock.off('error', onError) } - const settle = (result: boolean): void => { + const settle = (result: SocketProbeOutcome): void => { if (settled) { return } @@ -72,14 +82,16 @@ function canConnectSocket(socketPath: string): Promise { resolve(result) } const onConnect = (): void => { - settle(true) + settle('connected') sock.destroy() } - const onError = (): void => { - settle(false) + const onError = (error: NodeJS.ErrnoException): void => { + settle( + error.code === 'ECONNREFUSED' ? 'refused' : error.code === 'ENOENT' ? 'missing' : 'unknown' + ) } const timer = setTimeout(() => { - settle(false) + settle('unknown') sock.destroy() }, 500) sock.on('connect', onConnect) @@ -278,7 +290,12 @@ export function getMacDaemonSystemResolverHealth( } // Why: the daemon must report health from inside its own process; // external launchctl bsexec probes can misclassify healthy PTYs. - sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) + sock?.write( + encodeNdjson({ + id: 'resolver-health-1', + type: 'systemResolverHealth' + }) + ) continue } @@ -366,7 +383,10 @@ function getLinuxProcessStartedAtMs(pid: number): number | null { const startTicks = parseLinuxProcStartTicks(stat) const bootTimeSeconds = parseLinuxBootTimeSeconds(readFileSync('/proc/stat', 'utf8')) const ticksPerSecond = Number( - execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim() + execFileSync('getconf', ['CLK_TCK'], { + encoding: 'utf8', + timeout: 1_000 + }).trim() ) if ( !Number.isFinite(startTicks) || @@ -657,14 +677,45 @@ export async function isDaemonStaleForCurrentBundle( return true } +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + try { + process.kill(pid, 0) + } catch { + return true + } + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS)) + } +} + +export type StaleDaemonKillOutcome = { + /** A daemon was positively confirmed gone. Drives replacement telemetry. */ + killed: boolean + /** + * We could not prove the recorded daemon is gone. Its PID record and endpoint are left + * intact and the caller must not fork a replacement beside it — that is exactly how two + * daemons end up alive with one owning the socket and the other hosting the sessions. + */ + liveOwnerSurvived: boolean +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, tokenPath: string, protocolVersion = PROTOCOL_VERSION -): Promise { +): Promise { const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) let killedDaemon = false + let liveOwnerSurvived = false try { const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8')) if ( @@ -672,7 +723,16 @@ export async function killStaleDaemon( (await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) ) { const { pid, startedAtMs } = parsedPid - process.kill(pid, 'SIGTERM') + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + // Why: ESRCH means it is already gone. Anything else (EPERM from a daemon owned by + // another user) means it is alive and we cannot stop it — falling through to the + // blanket catch would report "nothing alive" and authorize a duplicate beside it. + if (!isNoSuchProcessError(error)) { + return { killed: false, liveOwnerSurvived: true } + } + } const deadline = Date.now() + KILL_WAIT_MS let exited = false while (Date.now() < deadline) { @@ -690,37 +750,70 @@ export async function killStaleDaemon( // daemon died during the wait. Without this, we'd SIGKILL an unrelated // process that happens to now own the same pid. if (!(await isDaemonProcess(pid, socketPath, tokenPath, startedAtMs))) { - console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled') - exited = true - killedDaemon = true + // Why: a failed identity probe has two causes with opposite correct actions — + // the pid really was recycled (daemon dead, reclaim the endpoint), or the probe + // itself failed under load (`ps` has a 2s timeout). The endpoint settles it: if + // something still answers the socket, a daemon is alive and must be preserved. + if ((await probeSocketConnect(socketPath)) === 'connected') { + console.warn( + '[daemon] Preserving daemon that could not be identified: reason=identity_probe_failed' + ) + liveOwnerSurvived = true + } else { + console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled') + exited = true + } } else { try { process.kill(pid, 'SIGKILL') - exited = true } catch { // Already dead + exited = true + } + // Why: issuing SIGKILL is not proof of death — a process blocked in an + // uninterruptible syscall only dies once it returns. Claiming the kill without + // confirming it is what let the endpoint be reclaimed out from under a daemon + // that was still alive and still serving. + exited = exited || (await waitForProcessExit(pid, SIGKILL_CONFIRM_WAIT_MS)) + if (!exited) { + console.warn('[daemon] Daemon survived SIGKILL: reason=unconfirmed_exit') } } } - killedDaemon = killedDaemon || exited + killedDaemon = exited + // Why: SIGTERM and SIGKILL both failed to produce an exit, so the daemon is still out + // there holding the endpoint. Treat it as the owner rather than racing it. + liveOwnerSurvived = liveOwnerSurvived || !exited } } catch { // PID file missing or process already dead } + if (liveOwnerSurvived) { + // Why: removing the record of a daemon we failed to kill is what let a replacement + // publish its own ownership beside it. Leaving it intact keeps the exclusive PID claim + // held, so a duplicate cannot take the endpoint. + return { killed: killedDaemon, liveOwnerSurvived } + } + try { unlinkSync(pidPath) } catch { // Best-effort } - const socketIsLive = await canConnectSocket(socketPath) - if (process.platform !== 'win32' && existsSync(socketPath) && (killedDaemon || !socketIsLive)) { + const socketOutcome = await probeSocketConnect(socketPath) + // Why: only positive evidence of a dead endpoint authorizes reclaiming the name. A probe + // that merely timed out leaves a live daemon's endpoint in place instead of unlinking it + // and forking a duplicate onto the freed path. + const endpointIsReclaimable = + killedDaemon || socketOutcome === 'refused' || socketOutcome === 'missing' + if (process.platform !== 'win32' && existsSync(socketPath) && endpointIsReclaimable) { try { unlinkSync(socketPath) } catch { // Best-effort } } - return killedDaemon + return { killed: killedDaemon, liveOwnerSurvived } } diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 4e288e4db9b..8fe0df0ac8a 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -10,6 +10,9 @@ export type DaemonEndpointIdentity = { pid: number startedAtMs: number launchNonce: string + /** Optional launch metadata. Absent from daemons that predate it; readers must fall back. */ + entryPath?: string + appVersion?: string } export type HelloResponse = { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 6f7de545dc4..1ef20756681 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -29,7 +29,11 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + replaceDaemonPidFileMock, + getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, + launchedStartedAtMs, + readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -85,16 +89,37 @@ const { const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) - const killStaleDaemonMock = vi.fn(async () => true) + const killStaleDaemonMock = vi.fn(async () => ({ + killed: true, + liveOwnerSurvived: false + })) const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) const parseDaemonPidFileMock = vi.fn( (): { pid: number; startedAtMs: number | null } | null => null ) + const replaceDaemonPidFileMock = vi.fn(() => true) + const getDaemonCommandLineMock = vi.fn(async (_pid: number): Promise => null) const unlinkOwnedDaemonPidFileMock = vi.fn(() => true) + const launchedStartedAtMs = { current: 1_000_000 } + + const readLaunchedDaemonIdentity = () => { + const args = forkMock.mock.calls.at(-1)?.[1] + const child = forkMock.mock.results.at(-1)?.value as { pid?: unknown } | undefined + const launchNonceIndex = Array.isArray(args) ? args.indexOf('--launch-nonce') : -1 + const launchNonce = launchNonceIndex >= 0 ? args[launchNonceIndex + 1] : undefined + return typeof child?.pid === 'number' && typeof launchNonce === 'string' + ? { + pid: child.pid, + startedAtMs: launchedStartedAtMs.current, + launchNonce + } + : null + } const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } @@ -170,7 +195,11 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + replaceDaemonPidFileMock, + getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, + launchedStartedAtMs, + readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -250,6 +279,7 @@ vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock, + getDaemonCommandLine: getDaemonCommandLineMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -290,7 +320,10 @@ vi.mock('./daemon-spawner', () => ({ const result = await override() const releaseAdoptionLease = vi.fn() adoptionLeaseReleases.push(releaseAdoptionLease) - this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } + this.handle = { + releaseAdoptionLease, + shutdown: vi.fn(async () => {}) + } if (result.mode) { this.handle.mode = result.mode } @@ -321,6 +354,8 @@ vi.mock('./daemon-spawner', () => ({ getDaemonPidPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj), + replaceDaemonPidFile: replaceDaemonPidFileMock, + sweepAbandonedDaemonClaims: vi.fn(() => 0), unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock })) @@ -419,7 +454,10 @@ async function importFresh() { // mockReset (not mockClear) also drops an unconsumed *Once queue, so a test that bails early // can't leak a queued false into the next test's confirmedReplacement gate. killStaleDaemonMock.mockReset() - killStaleDaemonMock.mockResolvedValue(true) + killStaleDaemonMock.mockResolvedValue({ + killed: true, + liveOwnerSurvived: false + }) getAppPathMock.mockReset() getAppPathMock.mockReturnValue('/fake/app') forkMock.mockReset() @@ -429,6 +467,7 @@ async function importFresh() { daemonClientMock.mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } @@ -444,6 +483,7 @@ async function importFresh() { parseDaemonPidFileMock.mockReturnValue(null) unlinkOwnedDaemonPidFileMock.mockReset() unlinkOwnedDaemonPidFileMock.mockReturnValue(true) + launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. @@ -486,7 +526,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockReturnValue(false) netConnectMock.mockReset() netConnectMock.mockImplementation(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -601,7 +644,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1) abortController.abort() - resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) + resolveEnsureRunning({ + socketPath: '/fake/socket-late', + tokenPath: '/fake/token-late' + }) await started expect(adapterInstances).toHaveLength(1) @@ -685,7 +731,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const result = await provider!.spawn({ cols: 80, rows: 24 }) expect(result.id).toBe('local-fallback-pty') - expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) + expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ + cols: 80, + rows: 24 + }) expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) @@ -888,7 +937,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => { await outgoingRespawn?.('daemon_died') respawnedMidRestart = true - return { socketPath: '/fake/restarted-socket', tokenPath: '/fake/restarted-token' } + return { + socketPath: '/fake/restarted-socket', + tokenPath: '/fake/restarted-token' + } }) await mod.restartDaemon() @@ -909,7 +961,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => { await restartedRespawn?.('daemon_died') respawnedMidSecondRestart = true - return { socketPath: '/fake/restarted-socket-2', tokenPath: '/fake/restarted-token-2' } + return { + socketPath: '/fake/restarted-socket-2', + tokenPath: '/fake/restarted-token-2' + } }) await mod.restartDaemon() @@ -1060,7 +1115,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive". probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -1081,7 +1139,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // The shutdown RPC must have been issued with killSessions=true. expect(ensureConnectedMock).toHaveBeenCalled() - expect(requestMock).toHaveBeenCalledWith('shutdown', { killSessions: true }) + expect(requestMock).toHaveBeenCalledWith('shutdown', { + killSessions: true + }) // The fallback killStaleDaemon must NOT fire when the RPC path worked. expect(killStaleDaemonMock).not.toHaveBeenCalled() }) @@ -1119,7 +1179,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await vi.advanceTimersByTimeAsync(1000) - await expect(cleanup).resolves.toEqual({ cleaned: false, killedCount: 0 }) + await expect(cleanup).resolves.toEqual({ + cleaned: false, + killedCount: 0 + }) expect(socket.destroy).toHaveBeenCalledTimes(1) expect(socket.listenerCount('connect')).toBe(0) expect(socket.listenerCount('error')).toBe(0) @@ -1182,7 +1245,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() - await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + await mod.initDaemonPtyProvider(undefined, { + macosLoginSessionWatch: true + }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, @@ -1279,6 +1344,192 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(disconnect).toHaveBeenCalledOnce() }) + it('repairs a stale PID record to the authenticated socket owner before adoption', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + readFileSyncMock.mockReturnValueOnce(JSON.stringify(endpointIdentity)) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher( + '/fake/socket', + '/fake/token', + '/fake/daemon.pid', + 'unused-new-launch' + ) + + expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', endpointIdentity) + expect(warn).toHaveBeenCalledWith( + '[daemon] Repaired daemon PID ownership to match the authenticated endpoint' + ) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('republishes the endpoint owner launch metadata into a repaired PID record', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + // Why: the mismatched record's metadata belongs to another daemon, so freshness and + // host-pinning fields must come from the authenticated owner — a record without + // appVersion reads as a permanently stale bundle and gets needlessly replaced. The + // install path deliberately contains a space: re-parsing it out of a space-joined + // command line truncated it and made a healthy daemon look like a different app path. + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner', + entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', + appVersion: '9.9.9' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') + + expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner', + entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', + appVersion: '9.9.9' + }) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('adopts the authenticated endpoint even when the PID record cannot be repaired', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + // Why: fail open. Losing every persistent terminal because a pid file write failed is a + // far worse outcome than a record that disagrees with the endpoint. + replaceDaemonPidFileMock.mockReturnValueOnce(false) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const forkCallsBefore = forkMock.mock.calls.length + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') + + expect(handle).toBeDefined() + // The healthy daemon is adopted, not replaced. + expect(forkMock.mock.calls.length).toBe(forkCallsBefore) + expect(warn).toHaveBeenCalledWith( + '[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway' + ) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('degrades instead of forking beside a daemon that could not be confirmed stopped', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const forkCallsBefore = forkMock.mock.calls.length + // Why: forking beside a survivor is exactly how the endpoint owner and the session host + // diverge — one daemon answers the socket while another hosts the visible terminals. But + // refusing outright would leave the user with no daemon at all, and something demonstrably + // still answers the endpoint, so adopt it degraded: live sessions keep working. + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: true + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ mode?: string; releaseAdoptionLease?(): void }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') + + expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(forkMock.mock.calls.length).toBe(forkCallsBefore) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + it('disconnects every temporary client when healthy adoption fails', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1590,7 +1841,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() await mod.initDaemonPtyProvider() checkDaemonHealthMock.mockResolvedValue('unreachable') - killStaleDaemonMock.mockResolvedValueOnce(false) + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: false + }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) @@ -1618,7 +1872,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // The daemon is gone before the launcher looks: nothing answers, nothing left to kill. checkDaemonHealthMock.mockResolvedValue('unreachable') - killStaleDaemonMock.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + killStaleDaemonMock + .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) + .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) @@ -1716,12 +1972,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { trackDaemonReplacedMock.mockClear() getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') - killStaleDaemonMock.mockResolvedValueOnce(false) + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: false + }) // The daemon answers cleanup's liveness probe, then the endpoint goes away so the self-shutdown // wait succeeds and cleanup reports cleaned:true. probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -1810,28 +2072,112 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() - const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] - expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) - expect(JSON.parse(pidContents as string)).toEqual({ - pid: 12345, - startedAtMs: 1_000_000, - linuxStartTicks: '4242', - bootId: 'boot-a', - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) - }) - expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] const launchNonceIndex = launchArgs.indexOf('--launch-nonce') expect(launchArgs).toEqual( expect.arrayContaining([ '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - '--launch-nonce' + '--launch-nonce', + expect.stringMatching(/^[0-9a-f-]{36}$/), + '--entry-path', + FAKE_DAEMON_ENTRY_PATH, + '--app-version', + '1.2.3' ]) ) - expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce) + expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/) + }) + + it.each([ + [ + 'changed endpoint identity', + () => ({ + getDaemonIdentity: vi.fn(() => ({ + pid: 999, + startedAtMs: 900_000, + launchNonce: 'stale-launch' + })) + }) + ], + ['missing endpoint identity reader', () => ({})] + ])('rejects and reaps a child with %s before adoption', async (_case, identityMethods) => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + function basicClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + } + daemonClientMock.mockImplementationOnce(basicClient) + daemonClientMock.mockImplementationOnce(basicClient) + daemonClientMock.mockImplementationOnce(function postReadyClient() { + return { + ...basicClient(), + ...identityMethods() + } + }) + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + kill: vi.fn(() => true), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + queueMicrotask(() => { + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + }) + return true + }) + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ shutdown(): Promise }> + + try { + await expect( + launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') + ).rejects.toThrow('Daemon endpoint ownership changed during startup') + + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 12345, + 'launch-new' + ) + } finally { + kill.mockRestore() + } }) it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => { @@ -1895,11 +2241,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed') ).rejects.toThrow('adoption unavailable') - expect(writeFileSyncMock).toHaveBeenCalledWith( - '/fake/daemon.pid', - expect.stringContaining('launch-delayed'), - { mode: 0o600, flag: 'wx' } - ) + expect(writeFileSyncMock).not.toHaveBeenCalled() expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce() @@ -2003,6 +2345,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() + expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() } finally { kill.mockRestore() vi.useRealTimers() @@ -2010,7 +2353,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('surfaces non-ESRCH startup termination errors and releases IPC', async () => { - const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + const signalError = Object.assign(new Error('operation not permitted'), { + code: 'EPERM' + }) const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw signalError }) @@ -2045,7 +2390,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + expect.objectContaining({ + message: 'Daemon readiness identity is incomplete' + }), signalError ]) expect(child.disconnect).toHaveBeenCalledOnce() @@ -2100,8 +2447,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' }) + expect.objectContaining({ + message: 'Daemon readiness identity is incomplete' + }), + expect.objectContaining({ + message: 'Daemon did not exit after SIGKILL' + }) ]) expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') @@ -2116,7 +2467,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - it('kills and rejects a daemon when exclusive PID publication fails', async () => { + it('leaves exclusive PID publication to the daemon child', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() @@ -2125,9 +2476,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> - const kill = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) - }) const child = { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { @@ -2140,27 +2488,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { disconnect: vi.fn(), unref: vi.fn() } - const publicationError = Object.assign(new Error('PID record already exists'), { - code: 'EEXIST' - }) - writeFileSyncMock.mockImplementationOnce(() => { - throw publicationError - }) forkMock.mockReturnValueOnce(child) - try { - await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError) - expect(writeFileSyncMock).toHaveBeenCalledWith( + await launcher('/fake/socket', '/fake/token') + + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(forkMock.mock.calls.at(-1)?.[1]).toEqual( + expect.arrayContaining([ + '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - expect.any(String), - { mode: 0o600, flag: 'wx' } - ) - expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') - expect(child.disconnect).not.toHaveBeenCalled() - expect(child.unref).toHaveBeenCalledOnce() - } finally { - kill.mockRestore() - } + '--entry-path', + FAKE_DAEMON_ENTRY_PATH, + '--app-version', + '1.2.3' + ]) + ) }) it('removes detached daemon startup listeners after startup error', async () => { @@ -2215,7 +2557,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const launcher = spawnerInstances[0].launcher as ( socketPath: string, - tokenPath: string + tokenPath: string, + pidPath?: string, + launchNonce?: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], @@ -2269,11 +2613,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } forkMock.mockReturnValueOnce(child) - const error = await launcher('/fake/socket', '/fake/token').catch((err: Error) => err) + const error = await launcher( + '/fake/socket', + '/fake/token', + '/fake/daemon.pid', + 'failed-launch' + ).catch((err: Error) => err) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toMatch(/Cannot find module 'electron'/) expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/) + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 4321, + 'failed-launch' + ) // Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure. expect(stderrDestroy).toHaveBeenCalled() }) @@ -2389,7 +2743,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string - ) => Promise<{ mode?: 'degraded-new-pty-fallback'; shutdown(): Promise }> + ) => Promise<{ + mode?: 'degraded-new-pty-fallback' + shutdown(): Promise + }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') const handle = await launcher('/fake/socket', '/fake/token') @@ -2542,7 +2899,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), - request: vi.fn(async () => ({ sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] })), + request: vi.fn(async () => ({ + sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] + })), disconnect: vi.fn() } }) @@ -2583,6 +2942,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { throw new Error('Hello response timed out') } }), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } @@ -2760,8 +3120,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => { - // Why: getProcessStartedAtMs has no cheap Windows impl, so the pid-recycling guard uses the ready-message fallback. + it('accepts the daemon self-reported start time when the OS query returns null', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -2771,6 +3130,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') getProcessStartedAtMsMock.mockReturnValue(null) + launchedStartedAtMs.current = 1_700_000_123_456 forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { @@ -2788,16 +3148,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] - expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) - expect(JSON.parse(pidContents as string)).toEqual({ - pid: 12345, - startedAtMs: 1_700_000_123_456, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) - }) - expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) + expect(writeFileSyncMock).not.toHaveBeenCalled() }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { @@ -2805,7 +3156,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() readFileSyncMock.mockReturnValue('{"pid":123}') // process.pid is guaranteed alive, so the liveness probe succeeds. - parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) + parseDaemonPidFileMock.mockReturnValue({ + pid: process.pid, + startedAtMs: null + }) await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index de040bb8efc..9a3339fc54e 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -3,7 +3,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { app } from 'electron' -import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { @@ -11,11 +11,13 @@ import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath, - serializeDaemonPidFile, + replaceDaemonPidFile, unlinkOwnedDaemonPidFile, type DaemonLauncher, + type DaemonPidFile, type DaemonProcessHandle } from './daemon-spawner' +import { sweepAbandonedDaemonClaims } from './daemon-endpoint-ownership' import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' @@ -53,12 +55,16 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' -import { parseDaemonReadyIdentity } from './daemon-ready-identity' +import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + logStartupDiagnostic(event, { + t: Math.round(performance.now()), + ...details + }) } } @@ -68,6 +74,24 @@ const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 +class DaemonEndpointOwnershipError extends Error {} + +/** + * The one method the ownership checks need from a connected client. + * + * Why: naming the capability instead of casting DaemonClient to Partial keeps a rename from + * silently turning the fence into a no-op — the compiler now rejects a client without it. + */ +type DaemonEndpointIdentityReader = { + getDaemonIdentity?: () => DaemonEndpointIdentity | null +} + +function readDaemonEndpointIdentity( + client: DaemonEndpointIdentityReader +): DaemonEndpointIdentity | null { + return client.getDaemonIdentity?.() ?? null +} + let spawner: DaemonSpawner | null = null type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -184,11 +208,25 @@ async function holdDaemonAdoptionLease( handle: DaemonProcessHandle, socketPath: string, tokenPath: string, - connectedClient?: DaemonClient + connectedClient?: DaemonClient, + expectedIdentity?: DaemonEndpointIdentity, + pidPath?: string ): Promise { const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath }) try { await client.ensureConnected() + if (expectedIdentity) { + const actualIdentity = readDaemonEndpointIdentity(client) + if ( + !actualIdentity || + actualIdentity.pid !== expectedIdentity.pid || + actualIdentity.startedAtMs !== expectedIdentity.startedAtMs || + actualIdentity.launchNonce !== expectedIdentity.launchNonce + ) { + throw new DaemonEndpointOwnershipError('Daemon endpoint ownership changed during startup') + } + } + await reconcileDaemonPidOwnership(client, pidPath) } catch (error) { client.disconnect() throw error @@ -197,6 +235,76 @@ async function holdDaemonAdoptionLease( return handle } +async function reconcileDaemonPidOwnership( + client: DaemonEndpointIdentityReader, + pidPath?: string +): Promise { + const endpointIdentity = readDaemonEndpointIdentity(client) + if (!pidPath || !endpointIdentity || pidRecordMatchesEndpoint(pidPath, endpointIdentity)) { + return + } + // Why: the mismatched record's metadata describes a different daemon and must not be copied + // onto this one. Re-derive it from the authenticated owner instead, so the repaired record + // keeps the fields freshness, host pinning and pid-recycle detection depend on. + const { pid, startedAtMs, launchNonce } = endpointIdentity + const ownerMetadata = await readDaemonOwnerMetadata(endpointIdentity) + if (!replaceDaemonPidFile(pidPath, { pid, startedAtMs, launchNonce, ...ownerMetadata })) { + // Why: fail open. A record that disagrees with the endpoint is a diagnosable nuisance; + // abandoning a healthy adoptable daemon over a failed file write costs the user every + // persistent terminal on the machine. + console.warn( + '[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway' + ) + return + } + console.warn('[daemon] Repaired daemon PID ownership to match the authenticated endpoint') +} + +/** + * Recovers the owning daemon's launch metadata. + * + * Why: `entryPath`/`appVersion` gate bundle-freshness and (on Windows) which relocated daemon + * host directories are pinned against pruning, and the Linux pair gates pid-recycle detection. + * Publishing a repaired record without them makes a healthy daemon look permanently stale. + * The values come from the authenticated hello rather than the owner's command line: a command + * line is a single space-joined string, so any install path containing a space (`C:\Program + * Files\...`, `/Applications/Orca 2.app/...`) cannot be split back into argv unambiguously. + */ +async function readDaemonOwnerMetadata( + identity: DaemonEndpointIdentity +): Promise> { + const metadata: Partial = {} + if (identity.entryPath) { + metadata.entryPath = identity.entryPath + } + if (identity.appVersion) { + metadata.appVersion = identity.appVersion + } + const incarnation = await readDaemonProcessIncarnation(identity.pid) + if (incarnation) { + metadata.linuxStartTicks = incarnation.linuxStartTicks + metadata.bootId = incarnation.bootId + } + return metadata +} + +function pidRecordMatchesEndpoint(pidPath: string, identity: DaemonEndpointIdentity): boolean { + try { + const record = JSON.parse(readFileSync(pidPath, 'utf8')) as { + pid?: unknown + startedAtMs?: unknown + launchNonce?: unknown + } + return ( + record.pid === identity.pid && + record.startedAtMs === identity.startedAtMs && + record.launchNonce === identity.launchNonce + ) + } catch { + return false + } +} + function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void { takeDaemonAdoptionLeaseRelease(handle)?.() } @@ -350,10 +458,14 @@ function createOutOfProcessLauncher( } | undefined let confirmedReplacement = false - let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath }) + let adoptionClient: DaemonClient | null = new DaemonClient({ + socketPath, + tokenPath + }) try { // Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap. await adoptionClient.ensureConnected() + await reconcileDaemonPidOwnership(adoptionClient, pidPath) } catch { adoptionClient.disconnect() adoptionClient = null @@ -367,7 +479,9 @@ function createOutOfProcessLauncher( createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode), socketPath, tokenPath, - connectedClient + connectedClient, + undefined, + pidPath ) } try { @@ -385,7 +499,10 @@ function createOutOfProcessLauncher( return preserveDaemon() } console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') - pendingReplacement = { reason: 'unhealthy_resolver', liveSessionCount } + pendingReplacement = { + reason: 'unhealthy_resolver', + liveSessionCount + } confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) .cleaned } else { @@ -470,14 +587,34 @@ function createOutOfProcessLauncher( } // Why: unlike the log above, telemetry gates on confirmedReplacement below — the // post-kill truth — so a cold start that killed nothing never reports a replacement. - pendingReplacement = { reason: 'failed_health_check', liveSessionCount } + pendingReplacement = { + reason: 'failed_health_check', + liveSessionCount + } } // Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one. adoptionClient?.disconnect() adoptionClient = null - confirmedReplacement = - (await killStaleDaemon(runtimeDir, socketPath, tokenPath)) || confirmedReplacement + const killOutcome = await killStaleDaemon(runtimeDir, socketPath, tokenPath) + if (killOutcome.liveOwnerSurvived) { + // Why: forking beside a daemon we could not prove dead is precisely how the endpoint + // owner and the session host diverge. But refusing outright would leave the user with + // no daemon at all, and we have just proved something still answers the endpoint — + // so adopt it in degraded mode: existing sessions keep working, new PTYs run locally. + console.warn( + '[daemon] DEGRADED MODE: adopting a daemon that could not be confirmed stopped. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).' + ) + try { + return await preserveDaemon('degraded-new-pty-fallback') + } catch { + // It died between the probe and the adoption; the endpoint is genuinely free now. + throw new DaemonEndpointOwnershipError( + 'Daemon replacement aborted: the existing daemon could not be confirmed stopped' + ) + } + } + confirmedReplacement = killOutcome.killed || confirmedReplacement // Why: rank by how well each reason is evidenced. A confirmed kill whose reason positively // identified the daemon outranks the attribution, so a stale bundle caught here is not billed // to the resolver. failed_health_check is the residual "couldn't tell" bucket though — it also @@ -514,6 +651,10 @@ function createOutOfProcessLauncher( pidPath, '--launch-nonce', launchNonce, + '--entry-path', + entryPath, + '--app-version', + app.getVersion(), ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...daemonLogArgs() ], @@ -557,6 +698,7 @@ function createOutOfProcessLauncher( } // Wait for the daemon to signal readiness via IPC + let launchedIdentity: DaemonEndpointIdentity | null = null await new Promise((resolve, reject) => { let timer: ReturnType | undefined let settled = false @@ -594,6 +736,9 @@ function createOutOfProcessLauncher( ) return } + if (Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } reject(startupError) } function onReadyMessage(msg: unknown): void { @@ -606,22 +751,10 @@ function createOutOfProcessLauncher( void fail(new Error('Daemon readiness identity is incomplete')) return } - try { - // Why: pid record shares the daemon's self time and nonce so cleanup can identify this exact process incarnation. - writeFileSync( - pidPath, - serializeDaemonPidFile({ - pid: child.pid as number, - ...readyIdentity, - entryPath, - appVersion: app.getVersion(), - launchNonce - }), - { mode: 0o600, flag: 'wx' } - ) - } catch (error) { - void fail(error instanceof Error ? error : new Error(String(error))) - return + launchedIdentity = { + pid: child.pid as number, + ...readyIdentity, + launchNonce } settled = true // Why: daemon is detached after readiness; detach startup listeners so the launch promise closure isn't retained. @@ -652,14 +785,25 @@ function createOutOfProcessLauncher( }) try { + if (!launchedIdentity) { + throw new Error('Daemon readiness identity is incomplete') + } return await holdDaemonAdoptionLease( { shutdown: () => terminateLaunchedDaemonChild(child) }, socketPath, - tokenPath + tokenPath, + undefined, + launchedIdentity, + pidPath ) } catch (error) { + if (error instanceof DaemonEndpointOwnershipError) { + await terminateLaunchedDaemonChild(child) + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + throw error + } // Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit. let pidRecordRemoved = false const removeExitedPidRecord = (): void => { @@ -698,6 +842,11 @@ export async function initDaemonPtyProvider( } const runtimeDir = getRuntimeDir() + // Why: rename-claim and bind scratch names are unlinked by their owner, but a failed unlink + // leaves one behind forever. Sweep before launching so a failed launch is still reclaimed; + // age-gated so a claim still in flight is never disturbed. + sweepAbandonedDaemonClaims(runtimeDir) + const newSpawner = new DaemonSpawner({ runtimeDir, launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false) @@ -797,7 +946,9 @@ export async function initDaemonPtyProvider( setLocalPtyProvider(routedAdapter) // Why: the first window may register PTY listeners before daemon init finishes; rebind so daemon PTYs still fan out events. rebindLocalProviderListeners() - logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + logDaemonMilestone('daemon-init-done', { + legacyAdapters: legacyAdapters.length + }) await reconcileSeededClaudeLivePtys(routedAdapter) } @@ -1084,7 +1235,8 @@ export async function cleanupDaemonForProtocol( didRequestShutdown = true } catch { // Previous-protocol daemons may be wedged or too old for the RPC path; fall back to PID cleanup (only unlinks a live socket after proving the process is killed). - didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion) + didKillStaleDaemon = (await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)) + .killed } finally { client.disconnect() } diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 3976c6c21e6..244a05f117b 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -7,6 +7,9 @@ export type DaemonStartOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + publishEndpointOwnership?: DaemonServerOptions['publishEndpointOwnership'] + entryPath?: string + appVersion?: string /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */ protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] @@ -15,6 +18,7 @@ export type DaemonStartOptions = { onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog onIdleShutdown?: () => void + onRpcShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] } @@ -29,6 +33,11 @@ export async function startDaemon(opts: DaemonStartOptions): Promise { + if (process.platform !== 'linux') { + return null + } + try { + const linuxStartTicks = parseLinuxStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8')) + const bootId = await readBootIdentity() + return linuxStartTicks && bootId ? { linuxStartTicks, bootId } : null + } catch { + return null + } +} + export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null { if (!message || typeof message !== 'object') { return null diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index b6588e44e69..053c5051033 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -87,11 +87,12 @@ describe('DaemonServer', () => { rmSync(dir, { recursive: true, force: true }) }) - async function startServer(launchNonce?: string): Promise { + async function startServer(launchNonce?: string, onRpcShutdown?: () => void): Promise { server = new DaemonServer({ socketPath, tokenPath, ...(launchNonce ? { pidPath, launchNonce } : {}), + ...(onRpcShutdown ? { onRpcShutdown } : {}), spawnSubprocess: () => createMockSubprocess() }) await server.start() @@ -842,7 +843,8 @@ describe('DaemonServer', () => { describe('shutdown', () => { it('waits for the ordinary shutdown reply write before destroying resources', async () => { - await startServer() + const onRpcShutdown = vi.fn() + await startServer(undefined, onRpcShutdown) const c = await connectClient() const daemon = server as unknown as DaemonServerPrivate & { host: { dispose: () => Promise } @@ -861,11 +863,14 @@ describe('DaemonServer', () => { await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({}) expect(dispose).not.toHaveBeenCalled() + expect(onRpcShutdown).not.toHaveBeenCalled() expect(existsSync(tokenPath)).toBe(true) replyFlushed?.() - await waitFor(() => !existsSync(tokenPath)) + await waitFor(() => onRpcShutdown.mock.calls.length === 1) + expect(existsSync(tokenPath)).toBe(false) expect(dispose).toHaveBeenCalledOnce() + expect(onRpcShutdown).toHaveBeenCalledOnce() }) it('removes only its owned token and PID record', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 4c9ffd5098e..6b13acc779f 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -2,7 +2,7 @@ import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync } from 'node:fs' +import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' @@ -23,6 +23,13 @@ import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' +import { + getDaemonSocketBindPath, + publishDaemonSocketPath, + readDaemonEndpointOwnershipState, + unlinkOwnedDaemonSocketPath, + type DaemonSocketIdentity +} from './daemon-endpoint-ownership' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -44,9 +51,14 @@ export type DaemonServerOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + publishEndpointOwnership?: () => void + /** Reported in the hello so a repaired PID record can carry the real owner's metadata. */ + entryPath?: string + appVersion?: string /** Direct-construction seam for protocol fixture tests; production never overrides it. */ protocolVersion?: number onIdleShutdown?: () => void + onRpcShutdown?: () => void /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ initialAdoptionTestConfig?: { timeoutMs: number @@ -95,6 +107,8 @@ export class DaemonServer { // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 + private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000 + private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2 private server: Server | null = null private token: string private host: TerminalHost @@ -103,8 +117,15 @@ export class DaemonServer { private pidPath: string | null private launchNonce: string | null private startedAtMs: number | null + private publishEndpointOwnership: () => void + private entryPath: string | null + private appVersion: string | null + private ownedSocketIdentity: DaemonSocketIdentity | null = null + private endpointOwnershipTimer: ReturnType | null = null + private endpointOwnershipLossStreak = 0 private protocolVersion: number private onIdleShutdown: () => void + private onRpcShutdown: () => void private onAuthenticatedClientPair: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise @@ -173,7 +194,11 @@ export class DaemonServer { (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? Date.now() - process.uptime() * 1000 : null) + this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) + this.entryPath = opts.entryPath ?? null + this.appVersion = opts.appVersion ?? null this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) + this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) this.initialAdoptionTimeoutMs = opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { @@ -213,19 +238,58 @@ export class DaemonServer { this.server.once('error', onListenError) - this.server.listen(this.socketPath, () => { + // Why: bind a private name and hard-link it into place, so libuv's close-time unlink + // can only ever remove our own bind name — never a replacement daemon's endpoint. + const bindPath = + process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath) + + this.server.listen(bindPath, () => { // Why: drop the startup error listener after bind so it doesn't retain this closure. this.server?.off('error', onListenError) - writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) try { - chmodSync(this.socketPath, 0o600) + // Why: tighten the mode on the private bind name so the endpoint is never reachable + // at the canonical path with default permissions, even briefly. + chmodSync(bindPath, 0o600) } catch { // Best-effort on platforms that support it } + let publishedOwnership = false + try { + // Why: the exclusive link is the endpoint claim, and the PID/nonce record must + // exist before the token makes this listener adoptable. + this.ownedSocketIdentity = publishDaemonSocketPath(bindPath, this.socketPath) + this.publishEndpointOwnership() + publishedOwnership = true + writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) + } catch (error) { + // Why: roll back only a record we actually wrote. Losing the endpoint claim means + // the record at that path belongs to the incumbent daemon, and even the ownership- + // checked unlink briefly renames it aside — enough to strand a live daemon's record. + if (publishedOwnership && this.pidPath && this.launchNonce) { + unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) + } + unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity) + this.ownedSocketIdentity = null + const server = this.server + this.server = null + // Why: settle before close — an already-accepted connection defers the close + // callback indefinitely, and start() has no timeout of its own. + reject(error) + server?.close() + if (process.platform !== 'win32') { + try { + unlinkSync(bindPath) + } catch { + // Already consumed by a successful link, or never created. + } + } + return + } if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { // Why: a parent crash before the first full client pair must not leave an empty daemon alive forever. this.armInitialAdoptionTimeout() } + this.startEndpointOwnershipWatch() resolve() }) }) @@ -253,15 +317,87 @@ export class DaemonServer { await serverClose } + private async finishRpcShutdown(serverClose: Promise): Promise { + await this.finishOrdinaryShutdown(serverClose) + this.onRpcShutdown() + } + private unlinkOwnedEndpointArtifacts(): void { - // Why: ownership checks prevent removing a late replacement's token or PID record. + // Why: ownership checks prevent removing a late replacement's token, PID record or endpoint. unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) if (this.pidPath && this.launchNonce) { unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) } + // Why: we bound a private name, so libuv unlinks nothing at the canonical path — this + // is the only removal of our endpoint, and it is skipped once someone else owns it. + unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity) + this.ownedSocketIdentity = null + } + + private startEndpointOwnershipWatch(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + this.endpointOwnershipTimer = setInterval( + () => this.checkEndpointOwnership(), + DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS + ) + // Why: a liveness poll must never be the reason the process cannot exit. + this.endpointOwnershipTimer.unref() + } + + private stopEndpointOwnershipWatch(): void { + if (this.endpointOwnershipTimer === null) { + return + } + clearInterval(this.endpointOwnershipTimer) + this.endpointOwnershipTimer = null + } + + /** + * Retires a daemon that no longer owns the canonical endpoint. + * + * Why: a daemon whose endpoint name was taken over keeps hosting PTYs that no client can + * reach through the socket, which reads to the user as terminals that acknowledge input and + * never run it. Retirement drains rather than kills: live sessions finish, and the process + * exits once idle instead of surviving as an unreachable orphan. + */ + private checkEndpointOwnership(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) { + return + } + const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity) + if (state === 'owned') { + this.endpointOwnershipLossStreak = 0 + return + } + if (state === 'indeterminate') { + // Why: an inconclusive stat proves nothing. Retiring on EACCES or EIO would take down a + // daemon that is still serving every terminal on the machine. Reset the streak too, so + // the confirmations we act on are consecutive rather than merely cumulative. + this.endpointOwnershipLossStreak = 0 + return + } + this.endpointOwnershipLossStreak++ + // Why: a replacement publishes by unlink-then-link, so a single observation can land in + // that gap. Require the loss to persist before acting on it. + if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) { + return + } + if (this.retirementRequested) { + return + } + this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath }) + console.warn( + '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' + ) + this.ownedSocketIdentity = null + this.retirementRequested = true + this.reevaluateIdleShutdown() } private async disposeDaemonResources(): Promise { + this.stopEndpointOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() this.cancelAllPendingPtySpawnPreparations() @@ -297,7 +433,9 @@ export class DaemonServer { return new Promise((resolve) => { // Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof. server.close(() => { - // Node owns unlinking its Unix listener; an extra unlink here could delete a concurrent replacement. + // Why: libuv unlinks the path this server bound, which is our private bind name and + // is already gone. The canonical endpoint is removed by unlinkOwnedEndpointArtifacts, + // under an ownership check, so closing late cannot delete a replacement's endpoint. resolve() }) }) @@ -436,19 +574,31 @@ export class DaemonServer { reason: 'protocol-mismatch', clientVersion: hello.version }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Protocol version mismatch' + }) + ) socket.destroy() return } if (hello.token !== this.token) { - this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) + this.log.log('client-hello-rejected', { + reason: 'invalid-token', + role: hello.role + }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) socket.destroy() return } - this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) + this.log.log('client-hello-accepted', { + role: hello.role, + clientId: hello.clientId + }) socket.write( encodeNdjson({ type: 'hello', @@ -458,7 +608,9 @@ export class DaemonServer { daemonIdentity: { pid: process.pid, startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce + launchNonce: this.launchNonce, + ...(this.entryPath ? { entryPath: this.entryPath } : {}), + ...(this.appVersion ? { appVersion: this.appVersion } : {}) } } : {}) @@ -639,7 +791,10 @@ export class DaemonServer { } private async preparePtySpawnUnlessCanceled(sessionId: string, clientId: string): Promise { - const preparation: PendingPtySpawnPreparation = { canceled: false, clientId } + const preparation: PendingPtySpawnPreparation = { + canceled: false, + clientId + } const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() pending.add(preparation) this.pendingPtySpawnPreparations.set(sessionId, pending) @@ -790,7 +945,10 @@ export class DaemonServer { }, onExit: (code, incarnationId) => { // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { sessionId: routedSessionId, code }) + this.log.log('session-exited', { + sessionId: routedSessionId, + code + }) this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { type: 'event', event: 'exit', @@ -942,7 +1100,9 @@ export class DaemonServer { clientId } try { - await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) + await this.host.kill(request.payload.sessionId, { + immediate: request.payload.immediate + }) } catch (error) { // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { @@ -960,14 +1120,18 @@ export class DaemonServer { case 'detach': // Note: detach token handling simplified — full impl would track tokens per client - this.log.log('session-detached', { sessionId: request.payload.sessionId }) + this.log.log('session-detached', { + sessionId: request.payload.sessionId + }) return {} case 'getCwd': return { cwd: await this.host.getCwd(request.payload.sessionId) } case 'getForegroundProcess': - return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + return { + foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) + } case 'inspectProcess': return this.host.inspectProcess(request.payload.sessionId) @@ -1019,7 +1183,9 @@ export class DaemonServer { typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) : undefined - const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows }) + const snapshot = this.host.getSnapshot(request.payload.sessionId, { + scrollbackRows + }) const snapshotMs = performance.now() - snapshotStart if (snapshotMs >= 25) { // Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family). @@ -1071,10 +1237,10 @@ export class DaemonServer { const controlSocket = this.clients.get(clientId)?.controlSocket if (controlSocket) { this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => - this.finishOrdinaryShutdown(serverClose) + this.finishRpcShutdown(serverClose) ) } else if (!this.shutdownPromise) { - this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + this.shutdownPromise = this.finishRpcShutdown(serverClose) } return {} } diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index 613b9e6a149..0c5b987851e 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -1,14 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, connect, type Server } from 'node:net' import { DaemonSpawner, getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath, + publishDaemonPidFile, + replaceDaemonPidFile, restoreClaimedDaemonArtifact } from './daemon-spawner' +import { + getDaemonSocketBindPath, + publishDaemonSocketPath, + sweepAbandonedDaemonClaims, + unlinkOwnedDaemonSocketPath +} from './daemon-endpoint-ownership' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' import type { SubprocessHandle } from './session' @@ -235,3 +245,214 @@ describe('restoreClaimedDaemonArtifact', () => { ).toBe(true) }) }) + +describe('daemon PID publication', () => { + it('publishes ownership exclusively', () => { + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + try { + publishDaemonPidFile(pidPath, { + pid: 101, + startedAtMs: 1_000, + launchNonce: 'launch-a' + }) + + expect(() => + publishDaemonPidFile(pidPath, { + pid: 202, + startedAtMs: 2_000, + launchNonce: 'launch-b' + }) + ).toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('atomically replaces stale ownership with the authenticated endpoint identity', () => { + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + const endpointIdentity = { + pid: 202, + startedAtMs: 2_000, + launchNonce: 'launch-b' + } + try { + writeFileSync(pidPath, '{"pid":101,"launchNonce":"launch-a"}') + + expect(replaceDaemonPidFile(pidPath, endpointIdentity)).toBe(true) + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual(endpointIdentity) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reports failure and preserves the record when the rename claim fails for any reason but absence', () => { + // A read-only parent denies the rename with EACCES, standing in for the Windows + // AV/indexer lock that fails the claim on a record which is merely open. + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + const existingRecord = '{"pid":101,"launchNonce":"launch-a"}' + writeFileSync(pidPath, existingRecord) + try { + chmodSync(dir, 0o500) + + expect( + replaceDaemonPidFile(pidPath, { pid: 202, startedAtMs: 2_000, launchNonce: 'launch-b' }) + ).toBe(false) + expect(readFileSync(pidPath, 'utf8')).toBe(existingRecord) + } finally { + chmodSync(dir, 0o700) + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +function listenOnSocketPath(server: Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, () => { + server.off('error', reject) + resolve() + }) + }) +} + +function closeSocketServer(server: Server): Promise { + return new Promise((resolve) => { + if (!server.listening) { + resolve() + return + } + server.close(() => resolve()) + }) +} + +function connectsToSocketPath(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = connect({ path: socketPath }) + const timer = setTimeout(() => { + socket.destroy() + resolve(false) + }, 500) + socket.on('connect', () => { + clearTimeout(timer) + socket.destroy() + resolve(true) + }) + socket.on('error', () => { + clearTimeout(timer) + resolve(false) + }) + }) +} + +describe('daemon socket publication', () => { + it('keeps the bind name shorter than the canonical endpoint', () => { + // sockaddr_un caps the path, so the private bind name must never extend it. + const canonicalPath = getDaemonSocketPath('/tmp/orca-daemon-runtime') + + expect(getDaemonSocketBindPath(canonicalPath).length).toBeLessThan(canonicalPath.length) + }) + + it('publishes a bound listener under the canonical endpoint and reclaims only its own entry', async () => { + if (process.platform === 'win32') { + return + } + const dir = createTestDir() + const canonicalPath = getDaemonSocketPath(dir) + const first = createServer((socket) => socket.end()) + const second = createServer((socket) => socket.end()) + try { + const firstBindPath = getDaemonSocketBindPath(canonicalPath) + await listenOnSocketPath(first, firstBindPath) + + const firstIdentity = publishDaemonSocketPath(firstBindPath, canonicalPath) + expect(firstIdentity).not.toBeNull() + expect(existsSync(canonicalPath)).toBe(true) + expect(existsSync(firstBindPath)).toBe(false) + await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) + + const secondBindPath = getDaemonSocketBindPath(canonicalPath) + await listenOnSocketPath(second, secondBindPath) + let publishError: NodeJS.ErrnoException | null = null + try { + publishDaemonSocketPath(secondBindPath, canonicalPath) + } catch (error) { + publishError = error as NodeJS.ErrnoException + } + expect(publishError?.code).toBe('EEXIST') + + expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(true) + expect(existsSync(canonicalPath)).toBe(false) + + // The endpoint name now resolves to a different listener's inode. + const secondIdentity = publishDaemonSocketPath(secondBindPath, canonicalPath) + expect(secondIdentity).not.toEqual(firstIdentity) + expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(false) + expect(existsSync(canonicalPath)).toBe(true) + await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) + } finally { + await closeSocketServer(first) + await closeSocketServer(second) + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('sweepAbandonedDaemonClaims', () => { + const claimNames = [ + `daemon-v${PROTOCOL_VERSION}.pid.cleanup-123-${randomUUID()}`, + `daemon-v${PROTOCOL_VERSION}.pid.replace-123-${randomUUID()}`, + '.b0123456789' + ] + const preservedNames = [`daemon-v${PROTOCOL_VERSION}.pid`, `daemon-v${PROTOCOL_VERSION}.token`] + + function seedClaimDir(): string { + const dir = createTestDir() + for (const name of [...claimNames, ...preservedNames]) { + writeFileSync(join(dir, name), 'x') + } + return dir + } + + it('removes aged claim and bind scratch names without touching daemon artifacts', () => { + const dir = seedClaimDir() + try { + expect(sweepAbandonedDaemonClaims(dir, undefined, Date.now() + 24 * 60 * 60 * 1000)).toBe( + claimNames.length + ) + + for (const name of claimNames) { + expect(existsSync(join(dir, name))).toBe(false) + } + for (const name of preservedNames) { + expect(existsSync(join(dir, name))).toBe(true) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('leaves freshly written claims alone so an in-flight claim is never stolen', () => { + const dir = seedClaimDir() + try { + expect(sweepAbandonedDaemonClaims(dir)).toBe(0) + + for (const name of [...claimNames, ...preservedNames]) { + expect(existsSync(join(dir, name))).toBe(true) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns zero for an unreadable runtime dir', () => { + expect(sweepAbandonedDaemonClaims(join(tmpdir(), `daemon-sweep-missing-${randomUUID()}`))).toBe( + 0 + ) + }) +}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 68b2a5afce4..2618cdf27aa 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,5 +1,13 @@ import { createHash, randomUUID } from 'node:crypto' -import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' +import { + constants, + copyFileSync, + existsSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' @@ -111,6 +119,56 @@ export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { return JSON.stringify(pidFile) } +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +export function publishDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): void { + writeFileSync(pidPath, serializeDaemonPidFile(pidFile), { + mode: 0o600, + flag: 'wx' + }) +} + +export function replaceDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): boolean { + const claimedPath = `${pidPath}.replace-${process.pid}-${randomUUID()}` + let claimedExisting = false + try { + renameSync(pidPath, claimedPath) + claimedExisting = true + } catch (error) { + // Why: only a genuinely absent record is safe to treat as unclaimed. On Windows an + // external opener without FILE_SHARE_DELETE (AV, indexer, backup) fails the rename + // with EPERM/EACCES/EBUSY; falling through would then hit EEXIST on the exclusive + // publish and report a false ownership conflict for a record that is simply locked. + if (!isMissingFileError(error)) { + return false + } + } + + try { + publishDaemonPidFile(pidPath, pidFile) + } catch { + if (claimedExisting && restoreClaimedDaemonArtifact(claimedPath, pidPath)) { + try { + unlinkSync(claimedPath) + } catch { + // A uniquely named restored claim is inert. + } + } + return false + } + + if (claimedExisting) { + try { + unlinkSync(claimedPath) + } catch { + // The canonical record is authoritative; the uniquely named claim is inert. + } + } + return true +} + export function unlinkOwnedDaemonPidFile( pidPath: string, expectedPid: number, @@ -118,7 +176,10 @@ export function unlinkOwnedDaemonPidFile( ): boolean { return claimAndUnlinkOwnedFile(pidPath, (content) => { try { - const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown } + const parsed = JSON.parse(content) as { + pid?: unknown + launchNonce?: unknown + } return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce } catch { return false diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 490f15124d7..4404c5c2c99 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createProductionLauncher } from './production-launcher' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -148,18 +148,18 @@ describe('createProductionLauncher', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalled() expect(child.unref).toHaveBeenCalled() - expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ - pid: 12345, - startedAtMs: 123_456, - linuxStartTicks: '4242', - bootId: 'boot-a', - entryPath: join(dir, 'daemon-entry.js'), - appVersion: '1.2.3', - launchNonce: 'launch-a' - }) expect(forkMock).toHaveBeenCalledWith( join(dir, 'daemon-entry.js'), - expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']), + expect.arrayContaining([ + '--pid-record', + pidPath, + '--launch-nonce', + 'launch-a', + '--entry-path', + join(dir, 'daemon-entry.js'), + '--app-version', + '1.2.3' + ]), expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) ) }) @@ -208,7 +208,9 @@ describe('createProductionLauncher', () => { getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), getAppVersion: () => '1.2.3' }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch @@ -216,6 +218,7 @@ describe('createProductionLauncher', () => { expect(child.kill).toHaveBeenCalledWith('SIGTERM') expect(child.exitCode).toBe(0) + expect(existsSync(pidPath)).toBe(false) }) it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { @@ -337,7 +340,7 @@ describe('createProductionLauncher', () => { } }) - it('preserves PID publication and cleanup failures', async () => { + it('preserves exact PID ownership when child exit cannot be confirmed', async () => { const handlers: Record void)[]> = { message: [], error: [], @@ -370,24 +373,65 @@ describe('createProductionLauncher', () => { unref: vi.fn() } forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'occupied.pid') - writeFileSync(pidPath, 'occupied') + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' + }) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') + handlers.message[0]?.({ type: 'ready' }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + expect(existsSync(pidPath)).toBe(true) + }) + + it('removes exact child-side PID ownership after a pre-ready exit', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) const launcher = createProductionLauncher({ getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), getAppVersion: () => '1.2.3' }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') + child.exitCode = 1 + handlers.exit[0]?.(1) - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ code: 'EEXIST' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() + await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') + expect(existsSync(pidPath)).toBe(false) }) }) diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 79bf439eead..342ff6e94ba 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -1,7 +1,6 @@ import { fork, type ChildProcess } from 'node:child_process' -import { writeFileSync } from 'node:fs' import { - serializeDaemonPidFile, + unlinkOwnedDaemonPidFile, type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' @@ -28,6 +27,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } const entryPath = opts.getDaemonEntryPath() + const appVersion = opts.getAppVersion() const child = fork( entryPath, [ @@ -35,7 +35,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo socketPath, '--token', tokenPath, - ...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : []) + ...(pidPath && launchNonce + ? [ + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + appVersion + ] + : []) ], { // Why: detached daemon output is not consumed; ignored streams cannot @@ -47,31 +58,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } ) - let readyIdentity: DaemonReadyIdentity try { - readyIdentity = await waitForReady(child) + await waitForReady(child) } catch (error) { - return rejectAfterChildCleanup(child, error) + return rejectAfterChildCleanup(child, error, pidPath, launchNonce) } - if (pidPath && launchNonce) { - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete')) - } - try { - writeFileSync( - pidPath, - serializeDaemonPidFile({ - pid: child.pid as number, - ...readyIdentity, - entryPath, - appVersion: opts.getAppVersion(), - launchNonce - }), - { mode: 0o600, flag: 'wx' } - ) - } catch (error) { - return rejectAfterChildCleanup(child, error) - } + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { + return rejectAfterChildCleanup( + child, + new Error('Daemon readiness identity is incomplete'), + pidPath, + launchNonce + ) } // Unref so the Electron process can exit without waiting for the daemon @@ -79,7 +77,10 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo child.disconnect() return { - shutdown: () => shutdownChild(child) + shutdown: async () => { + await shutdownChild(child) + unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) + } } } } @@ -208,7 +209,12 @@ async function shutdownChild(child: ChildProcess): Promise { } } -async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise { +async function rejectAfterChildCleanup( + child: ChildProcess, + launchError: unknown, + pidPath?: string, + launchNonce?: string +): Promise { try { await shutdownChild(child) } catch (cleanupError) { @@ -217,9 +223,20 @@ async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown 'Daemon launch and child cleanup both failed' ) } + unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) throw launchError } +function unlinkLaunchedDaemonPidFile( + child: ChildProcess, + pidPath?: string, + launchNonce?: string +): void { + if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } +} + function isNoSuchProcessError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } From b0ba51831c1cd8c29c73482c6caaed15beced75f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:17:45 -0700 Subject: [PATCH 27/88] Add per-worker model and effort overrides (#12851) --- .../orchestration-skill-guidance.test.mjs | 10 ++ skill-guides/orchestration.md | 8 + src/cli/bundled-skill-guides.ts | 2 +- .../handlers/orchestration-worker-cli.test.ts | 58 +++++++ src/cli/handlers/orchestration.ts | 20 ++- src/cli/help.ts | 2 + src/cli/specs/orchestration-worker-specs.ts | 5 +- src/main/runtime/orca-runtime.ts | 18 +- .../orchestration-federated-worker-start.ts | 75 ++++---- ...hestration-federation-agent-launch.test.ts | 24 ++- .../orchestration-federation-start-receipt.ts | 3 + .../orchestration-federation-start-schema.ts | 5 + .../orchestration-federation-test-request.ts | 25 +++ .../methods/orchestration-federation.test.ts | 41 +++-- .../rpc/methods/orchestration-federation.ts | 50 ++---- ...stration-worker-launch-preferences.test.ts | 135 ++++++++++++++ ...orchestration-worker-launch-preferences.ts | 158 +++++++++++++++++ .../orchestration-worker-start-receipt.ts | 3 + .../orchestration-worker-start-schema.ts | 9 + .../orchestration-worker-start-validation.ts | 164 ++++++++++++++++++ .../methods/orchestration-worker-topology.ts | 5 + ...orchestration-workers-new-worktree.test.ts | 23 +++ .../rpc/methods/orchestration-workers.ts | 41 +---- .../runtime/rpc/methods/orchestration.test.ts | 55 ++++++ src/shared/agent-session-option-agent-args.ts | 35 ++++ ...ent-session-option-catalog-claude-codex.ts | 44 ++++- ...nt-session-option-catalog-gemini-cursor.ts | 5 +- .../agent-session-option-catalog-types.ts | 6 + src/shared/agent-session-option-launch.ts | 52 ++++-- src/shared/protocol-version.ts | 3 + src/shared/tui-agent-launch-command.ts | 59 ++++++- .../tui-agent-startup-session-options.test.ts | 54 ++++++ src/shared/tui-agent-startup.ts | 6 +- 33 files changed, 1039 insertions(+), 164 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration-federation-test-request.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts create mode 100644 src/shared/agent-session-option-agent-args.ts diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index e869f652e40..080cb4fa785 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => { ) }) + it('documents per-invocation model and effort for supervised workers', () => { + const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop') + + expect(workerLoop).toContain('opaque provider model id with `--model`') + expect(workerLoop).toContain('`--effort` requires `--model`') + expect(workerLoop).toContain('neither option can combine with `--terminal`') + expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('`launch.requested` and `launch.effective`') + }) + it('never authorizes release from idle, timeout, or worker-side triggers', () => { const skill = readSkill() const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 7da8936ea1a..bc7b1cbbe3e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -192,6 +192,14 @@ orca orchestration worker-start --task --worktree current --agent claud `current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `. +For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: + +```bash +orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +``` + +`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. + For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal: ```bash diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index fadfd439030..1fcb8e0afd1 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 95a0a7db313..a2777ec3fdb 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { printResult } from '../format' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' describe('orchestration worker-start CLI contract', () => { beforeEach(() => { @@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it('capability-gates and forwards per-invocation launch preferences', async () => { + callMock + .mockResolvedValueOnce({ + result: { + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY] + } + }) + .mockResolvedValueOnce({ + result: { + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['model', 'aws-bedrock-opus-5'], + ['effort', 'high'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ) + }) + + it('fails before worker-start when the runtime would strip launch preferences', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['model', 'gpt-5.6-sol'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) + + expect(callMock).toHaveBeenCalledTimes(1) + }) + it('sets an unsuccessful exit code for failed and unknown receipts', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index f9cbdb8a41a..6668b4e1906 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -20,7 +20,8 @@ import type { OrchestrationWorkerReadSource } from '../../shared/orchestration-worker-output' import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeTerminalRead } from '../../shared/runtime-types' +import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { @@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } const result = await callMutation<{ runId: string taskId: string @@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record = { comment: getOptionalStringFlag(flags, 'comment'), setup: getOptionalStringFlag(flags, 'setup'), agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/help.ts b/src/cli/help.ts index e866a91ef22..3415ddad832 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string { local: '--local Target the current project instead of the global install', skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', + model: '--model Provider model id for a new agent launch', + effort: '--effort Reasoning effort for the selected model', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', name: '--name Name for the new worktree or automation', diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index a8d8b2411f5..b7f6b583a73 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'comment', 'setup', 'agent', + 'model', + 'effort', 'terminal', 'retry-of', 'timeout-ms', @@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', + '--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', '--on selects only the worker server; the Run and this command remain on the current Orca server.', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1a2aed7a74b..80a04d60554 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1293,6 +1293,7 @@ type TerminalCreateOptions = { // CLI is `cursor-agent`). Callers that know the agent name it here instead of // guessing a command, and the runtime builds the configured launch. startupAgent?: TuiAgent + launchPreferences?: AgentLaunchPreferences terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -20956,7 +20957,8 @@ export class OrcaRuntimeService { private buildStartupForAgent( repo: Repo, agent: TuiAgent, - prompt: string | undefined + prompt: string | undefined, + launchPreferences?: AgentLaunchPreferences ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { if (!this.store) { throw new Error('runtime_unavailable') @@ -20974,12 +20976,15 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) + const sessionOptions = this.toAgentSessionOptions(launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform: agentLaunchPlatform, shell: queuedShell, isRemote, @@ -21418,6 +21423,7 @@ export class OrcaRuntimeService { observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent + startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance @@ -21450,7 +21456,12 @@ export class OrcaRuntimeService { } const agentStartup = !args.startup && args.startupAgent - ? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) + ? this.buildStartupForAgent( + repo, + args.startupAgent, + args.startupPrompt, + args.startupLaunchPreferences + ) : null const draftStartup = !args.startup && !agentStartup && args.startupDraft @@ -24619,12 +24630,15 @@ export class OrcaRuntimeService { return opts } + const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform, shell: queuedShell, isRemote, diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 538dadc96d0..23a6dc0928f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -11,6 +11,14 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { WorkerStartInput } from './orchestration-worker-start-schema' +import { + assertWorkerLaunchPreferencesRuntimeSupported, + assertWorkerLaunchPreferencesCreateTerminal, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + type OrchestrationWorkerLaunchReceipt +} from './orchestration-worker-launch-preferences' +import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -40,7 +48,13 @@ export async function startFederatedWorker(args: { ) } const createsWorktree = worktree === 'new-top-level' - validateRemoteWorkerStart(params, createsWorktree) + assertWorkerLaunchPreferencesCreateTerminal(params) + validateFederatedWorkerStartPlacement(params, createsWorktree) + const requestedLaunch = createPendingWorkerLaunchReceipt({ + agent: isTuiAgent(params.agent) ? params.agent : null, + model: params.model, + effort: params.effort + }) const server = runtime.resolveOrchestrationWorkerServer(params.on as string) const status = (await runtime.callOrchestrationWorkerServer( @@ -62,6 +76,12 @@ export async function startFederatedWorker(args: { `Connected server ${server.name} does not support orchestration federation.` ) } + assertWorkerLaunchPreferencesRuntimeSupported({ + model: params.model, + effort: params.effort, + capabilities: status.capabilities, + serverName: server.name + }) const federationProtocolVersion = status.capabilities?.includes( ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY ) @@ -81,6 +101,7 @@ export async function startFederatedWorker(args: { baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: params.agent ?? null, + launch: requestedLaunch, timeoutMs: params.timeoutMs ?? 60_000, setup: setupDecision, setupSource: createsWorktree @@ -122,6 +143,8 @@ export async function startFederatedWorker(args: { : undefined, terminal: params.terminal, agent: params.agent, + model: params.model, + effort: params.effort, timeoutMs: params.timeoutMs, devMode: params.devMode }, @@ -134,6 +157,11 @@ export async function startFederatedWorker(args: { 'The worker server returned a different Dispatch attachment.' ) } + const launch = resolveFederatedWorkerLaunchReceipt( + remote.launch, + requestedLaunch, + remote.state === 'ready' + ) if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { db.updateFederatedDispatchResources({ dispatchId: started.dispatch.id, @@ -160,6 +188,7 @@ export async function startFederatedWorker(args: { stage: readyWorker.stage, server: { environmentId: server.environmentId, name: server.name }, setup: remote.setup, + launch, timeoutMs: params.timeoutMs ?? 60_000, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] @@ -171,7 +200,7 @@ export async function startFederatedWorker(args: { remote.failedStage ?? 'remote_attach', remote.lastError ?? 'The worker server reported an unknown start outcome.' ) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, launch) } const worker = db.failWorkerStart( started.dispatch.id, @@ -188,6 +217,7 @@ export async function startFederatedWorker(args: { failedStage: worker.stage, lastError: worker.last_error, setup: remote.setup, + launch, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] } @@ -204,12 +234,13 @@ export async function startFederatedWorker(args: { server: { environmentId: server.environmentId, name: server.name }, failedStage: worker.stage, lastError: worker.last_error, + launch: requestedLaunch, effects: [], residualResources: [] } } const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, requestedLaunch) } } @@ -220,45 +251,13 @@ type RemoteStartReceipt = { worktreeId?: string terminalHandle?: string setup?: { state: string } + launch?: OrchestrationWorkerLaunchReceipt effects?: unknown[] residualResources?: unknown[] failedStage?: string lastError?: string } -function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void { - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when remote worker-start creates a terminal.' - ) - } -} - function isKnownRemoteStartFailure(code: string): boolean { return [ 'invalid_argument', @@ -272,7 +271,8 @@ function isKnownRemoteStartFailure(code: string): boolean { function federatedUnknownReceipt( worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, taskId: string, - serverName: string + serverName: string, + launch: OrchestrationWorkerLaunchReceipt ): unknown { return { taskId, @@ -280,6 +280,7 @@ function federatedUnknownReceipt( state: 'outcome_unknown', stage: worker.stage, server: { name: serverName }, + launch, failedStage: worker.stage, lastError: worker.last_error, effects: [], diff --git a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts index 15b2194140a..486821231e1 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts @@ -61,7 +61,9 @@ describe('federated worker agent launch', () => { taskSpec: 'remote cursor worker', protocolVersion: 1, worktree: 'id:repo::remote-worktree', - agent: 'cursor' + agent: 'cursor', + model: 'gpt-5.3-codex', + effort: 'high' }), { runtime, @@ -72,14 +74,28 @@ describe('federated worker agent launch', () => { payloadHash: 'remote_payload' } } - )) as { state: string; failedStage?: string; lastError?: string } + )) as { + state: string + failedStage?: string + lastError?: string + launch: unknown + } // Why: assert the worker actually reached ready — a spy-only assertion would // stay green even if every stage after terminal_create regressed. - expect(result).toMatchObject({ state: 'ready' }) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' }, + effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' } + } + }) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', - expect.objectContaining({ startupAgent: 'cursor' }) + expect.objectContaining({ + startupAgent: 'cursor', + launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' } + }) ) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts index b7e2da76531..fa9b60facba 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts @@ -1,6 +1,7 @@ import type { OrchestrationDb } from '../../orchestration/db' import { isFederationEffectUnknown } from './orchestration-federation-effects' import type { WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failFederatedAttachmentWithReceipt(args: { db: OrchestrationDb @@ -9,6 +10,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isFederationEffectUnknown(args.error, args.failedStage) @@ -26,6 +28,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(attachment.effects) as unknown[], residualResources: JSON.parse(attachment.residual_resources) as unknown[] } diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index a657ff7d09c..e6bea35c20a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-schema' export const FederationAttachStartParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), @@ -16,6 +17,10 @@ export const FederationAttachStartParams = z.object({ setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() }) + +export type FederationAttachStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts new file mode 100644 index 00000000000..7d004775310 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts @@ -0,0 +1,25 @@ +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import type { RpcRequest } from '../core' + +export function createFederationWorkerStartRequest( + taskId: string, + overrides: Record = {} +): RpcRequest { + return { + id: 'rpc_worker_start', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_windows_worker', + method: 'orchestration.workerStart', + params: { + task: taskId, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'windows-audit', + agent: 'codex', + ...overrides + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index cf9224c875c..e797a09c256 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -8,8 +8,8 @@ import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' import { ORCHESTRATION_METHODS } from './orchestration' +import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request' describe('orchestration federation', () => { const databases: OrchestrationDb[] = [] @@ -97,26 +97,6 @@ describe('orchestration federation', () => { return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) } - function startRequest(taskId: string, overrides: Record = {}): RpcRequest { - return { - id: 'rpc_worker_start', - authToken: 'coordinator-token', - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: 'request_windows_worker', - method: 'orchestration.workerStart', - params: { - task: taskId, - from: 'term_coord', - on: 'windows', - worktree: 'new-top-level', - repo: 'id:windows-repo', - name: 'windows-audit', - agent: 'codex', - ...overrides - } - } - } - function configureWorkerRuntime(runtime: OrcaRuntimeService): void { vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) vi.spyOn(runtime, 'showRepo').mockResolvedValue({ @@ -230,6 +210,25 @@ describe('orchestration federation', () => { ) }) + it('does not report remotely rejected preferences as effective', async () => { + const task = createHomeTask() + + const response = await homeDispatcher.dispatch( + startRequest(task.id, { agent: 'grok', model: 'unsupported-model' }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + launch: { + requested: { agent: 'grok', model: 'unsupported-model', effort: null }, + effective: null + } + } + }) + }) + it('preserves wait-for-setup gating on the connected worker server', async () => { vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({ worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index ce173290848..ffde742ecee 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -18,6 +17,7 @@ import { } from './orchestration-federation-setup' import { FederationAttachStartParams } from './orchestration-federation-start-schema' import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' +import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ defineMethod({ @@ -37,43 +37,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ ) } const createsWorktree = params.worktree === 'new-top-level' - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'A remote new-top-level worktree requires --name and an explicit --repo.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if ( - !createsWorktree && - (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when federated worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareFederationAttachmentWorkerStart({ + params, + createsWorktree, + runtime + }) if (createsWorktree) { await assertOrchestrationWorktreeCreationSupported({ runtime, @@ -126,6 +94,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ observeSetupCompletion: true, createdWithAgent: agent as TuiAgent, startupAgent: agent as TuiAgent, + ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), activate: false, lineage: { noParent: true } }) @@ -196,6 +165,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ // Why: agent ids are not shell commands (`cursor` is the desktop app, // its CLI is `cursor-agent`); resolve through the TUI agent config. startupAgent: agent as TuiAgent, + ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), title: `worker-${params.taskId}`, presentation: 'background' }) @@ -284,6 +254,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ worktreeId: worktree.id, terminalHandle, setup, + launch: launch.receipt, effects, residualResources: [] } @@ -294,7 +265,8 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ runtimeEpoch: runtime.getRuntimeId(), failedStage, error, - setup + setup, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts new file mode 100644 index 00000000000..dd17945445c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertWorkerLaunchPreferencesCreateTerminal, + assertWorkerLaunchPreferencesRuntimeSupported, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import { WorkerStartParams } from './orchestration-worker-start-schema' + +describe('orchestration worker launch preferences', () => { + it('passes an opaque Claude model and portable effort through the shared catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ).toEqual({ + preferences: { model: 'aws-bedrock-opus-5', effort: 'high' }, + receipt: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + }) + + it('does not invent an effort when only a model is requested', () => { + expect( + resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences + ).toEqual({ model: 'gpt-5.6-sol' }) + }) + + it('rejects model-specific effort combinations the catalog disproves', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'codex', + model: 'gpt-5.6-luna', + effort: 'xhigh' + }) + ).toThrow('does not support effort xhigh') + }) + + it('rejects effort without a model', () => { + expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow( + '--effort requires --model' + ) + }) + + it('rejects model selection for agents without a launch catalog', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'grok', model: 'grok-code-fast-1' }) + ).toThrow('does not support launch-time model selection') + }) + + it('does not expose deprecated Gemini model selection to worker-start', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' }) + ).toThrow('does not support launch-time model selection') + }) + + it('rejects preferences when reusing an existing terminal', () => { + expect(() => + assertWorkerLaunchPreferencesCreateTerminal({ + terminal: 'term_existing', + model: 'gpt-5.6-sol' + }) + ).toThrow('cannot be applied when reusing an existing terminal') + }) + + it('requires remote capability support only for explicit preferences', () => { + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [], + serverName: 'windows' + }) + ).toThrow('does not support worker model or effort overrides') + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + capabilities: [], + serverName: 'windows' + }) + ).not.toThrow() + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY], + serverName: 'windows' + }) + ).not.toThrow() + }) + + it('uses the requested launch receipt when an older worker omits it', () => { + const requested = createPendingWorkerLaunchReceipt({ + agent: 'codex', + model: 'gpt-5.6-sol', + effort: 'high' + }) + + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, true)).toEqual({ + requested: requested.requested, + effective: requested.requested + }) + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, false)).toBe(requested) + }) + + it.each([' custom-model', 'custom-model '])( + 'rejects model ids with surrounding whitespace: %j', + (model) => { + expect(WorkerStartParams.safeParse({ task: 'task_1', agent: 'codex', model }).success).toBe( + false + ) + } + ) + + it('bounds opaque launch preferences', () => { + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'm'.repeat(513) + }).success + ).toBe(false) + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'custom-model', + effort: 'e'.repeat(513) + }).success + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts new file mode 100644 index 00000000000..76c60064691 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts @@ -0,0 +1,158 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { TuiAgent } from '../../../../shared/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export type OrchestrationWorkerLaunchSelection = { + agent: TuiAgent | null + model: string | null + effort: string | null +} + +export type OrchestrationWorkerLaunchReceipt = { + requested: OrchestrationWorkerLaunchSelection + effective: OrchestrationWorkerLaunchSelection | null +} + +export function createWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + const selection = { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + } + return { requested: selection, effective: { ...selection } } +} + +export function createPendingWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + return { + requested: { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + }, + effective: null + } +} + +export function resolveWorkerLaunchPreferences(args: { + agent: TuiAgent + model?: string + effort?: string +}): { + preferences: AgentLaunchPreferences | undefined + receipt: OrchestrationWorkerLaunchReceipt +} { + if (args.effort && !args.model) { + throw new OrchestrationError('invalid_argument', '--effort requires --model.') + } + if (!args.model) { + return { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: args.agent }) + } + } + + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} does not support launch-time model selection.` + ) + } + + if (args.effort) { + const model = findCatalogModel(catalog, args.model) + const option = + findCatalogOption(model, 'effort') ?? + (!model + ? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort') + : undefined) + if ( + option?.kind.type !== 'select' || + !option.kind.choices.some((choice) => choice.value === args.effort) + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.` + ) + } + } + + const requested = { + model: args.model, + ...(args.effort ? { effort: args.effort } : {}) + } + const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false) + if ( + resolved.appliedValues.model !== args.model || + resolved.appliedValues.effort !== args.effort + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} cannot apply the requested worker launch preferences.` + ) + } + + const preferences: AgentLaunchPreferences = requested + return { + preferences, + receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences }) + } +} + +export function assertWorkerLaunchPreferencesCreateTerminal(args: { + terminal?: string + model?: string + effort?: string +}): void { + if (args.terminal && (args.model || args.effort)) { + throw new OrchestrationError( + 'invalid_argument', + '--model and --effort cannot be applied when reusing an existing terminal.' + ) + } +} + +export function assertWorkerLaunchPreferencesRuntimeSupported(args: { + model?: string + effort?: string + capabilities?: readonly string[] + serverName: string +}): void { + if ( + (args.model || args.effort) && + !args.capabilities?.includes(ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY) + ) { + throw new OrchestrationError( + 'capability_unsupported', + `Connected server ${args.serverName} does not support worker model or effort overrides.` + ) + } +} + +export function resolveFederatedWorkerLaunchReceipt( + remote: OrchestrationWorkerLaunchReceipt | undefined, + requested: OrchestrationWorkerLaunchReceipt, + remoteReady: boolean +): OrchestrationWorkerLaunchReceipt { + if (remote) { + return remote + } + return remoteReady + ? { requested: requested.requested, effective: { ...requested.requested } } + : requested +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index 167d4610984..e58f9fff01e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -3,6 +3,7 @@ import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -12,6 +13,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) @@ -27,6 +29,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], ...(unknown diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts index d577598c966..cf081f9b36c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts @@ -1,6 +1,13 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +export const OptionalWorkerLaunchPreference = z + .string() + .min(1) + .max(512) + .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') + .optional() + export const WorkerStartParams = z.object({ task: requiredString('Missing --task'), on: OptionalString, @@ -15,6 +22,8 @@ export const WorkerStartParams = z.object({ setup: z.enum(['run', 'skip', 'inherit']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts new file mode 100644 index 00000000000..f6bb103f37e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts @@ -0,0 +1,164 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { FederationAttachStartInput } from './orchestration-federation-start-schema' +import { + assertWorkerLaunchPreferencesCreateTerminal, + createWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import type { WorkerStartInput } from './orchestration-worker-start-schema' + +type WorkerStartLaunch = ReturnType + +export function validateFederatedWorkerStartPlacement( + params: WorkerStartInput, + createsWorktree: boolean +): void { + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when remote worker-start creates a terminal.' + ) + } +} + +export function prepareLocalWorkerStart(args: { + params: WorkerStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with new-worktree creation.' + ) + } + if (createsWorktree && !params.name) { + throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to new-child or new-top-level worktrees.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.' + }) +} + +export function prepareFederationAttachmentWorkerStart(args: { + params: FederationAttachStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'A remote new-top-level worktree requires --name and an explicit --repo.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if ( + !createsWorktree && + (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: + 'A configured --agent is required when federated worker-start creates a terminal.' + }) +} + +function resolveWorkerStartAgent(args: { + runtime: OrcaRuntimeService + terminal?: string + agent?: string + model?: string + effort?: string + missingAgentMessage: string +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + if (!args.terminal && (!args.agent || !isTuiAgent(args.agent))) { + throw new OrchestrationError('agent_unconfigured', args.missingAgentMessage) + } + const agent = args.agent as TuiAgent | undefined + if (agent) { + args.runtime.validateOrchestrationAgentLauncher(agent) + return { + agent, + launch: resolveWorkerLaunchPreferences({ + agent, + model: args.model, + effort: args.effort + }) + } + } + return { + agent: undefined, + launch: { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: null }) + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 122a5b869ba..9672560ab1e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -1,3 +1,4 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' import type { TuiAgent } from '../../../../shared/types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' @@ -56,6 +57,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { runtime: OrcaRuntimeService worktreeId: string agent: TuiAgent + launchPreferences?: AgentLaunchPreferences taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -64,6 +66,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. @@ -117,6 +120,7 @@ export async function createWorkerWorktree(args: { from: string } agent: TuiAgent + launchPreferences?: AgentLaunchPreferences effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -139,6 +143,7 @@ export async function createWorkerWorktree(args: { observeSetupCompletion: true, createdWithAgent: args.agent, startupAgent: args.agent, + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), activate: false, lineage: { parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, diff --git a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts index 8e0a164178b..b83ab5b087b 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts @@ -157,6 +157,29 @@ describe('orchestration new-worktree workers', () => { expect(runtime.createTerminal).not.toHaveBeenCalled() }) + it('passes launch preferences into agent-first worktree creation', async () => { + mockCreatedWorktree() + + const { result } = await startWorker({ + model: 'custom-codex-model', + effort: 'high' + }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + startupAgent: 'codex', + startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' } + }) + ) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'codex', model: 'custom-codex-model', effort: 'high' }, + effective: { agent: 'codex', model: 'custom-codex-model', effort: 'high' } + } + }) + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index dc659773a8a..229253ec618 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -20,6 +19,7 @@ import { persistWorkerSetupWaitOutcome } from './orchestration-worker-setup-gate' import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' +import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ @@ -57,37 +57,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with new-worktree creation.' - ) - } - if (createsWorktree && !params.name) { - throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to new-child or new-top-level worktrees.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) const coordinatorTerminal = await runtime.showTerminal(params.from) const coordinatorWorktree = await runtime.showManagedWorktree( @@ -130,6 +100,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: agent ?? null, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', setupSource: createsWorktree @@ -174,6 +145,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ coordinatorWorktree, params, agent: agent as TuiAgent, + launchPreferences: launch.preferences, effects }) resolvedWorktree = created.worktree @@ -190,6 +162,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ runtime, worktreeId: resolvedWorktree!.id, agent: agent as TuiAgent, + launchPreferences: launch.preferences, taskId: task.id, effects }) @@ -281,6 +254,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ state: worker.state, stage: worker.stage, setup: setupReceipt, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, effects, residualResources: [], @@ -294,7 +268,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ dispatchId: started.dispatch.id, failedStage, error, - setup: setupReceipt + setup: setupReceipt, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 8ba154c48f7..a90bbe80410 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -2235,6 +2235,61 @@ describe('orchestration RPC methods', () => { ) }) + it('applies and reports opaque per-invocation model preferences', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'launch a custom model' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + })) as { + dispatchId: string + state: string + launch: { + requested: { agent: string; model: string; effort: string } + effective: { agent: string; model: string; effort: string } + } + } + + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ + startupAgent: 'claude', + launchPreferences: { model: 'aws-bedrock-opus-5', effort: 'high' } + }) + ) + expect(JSON.parse(db.getWorkerDispatch(result.dispatchId)!.start_options)).toMatchObject({ + launch: result.launch + }) + }) + + it('rejects launch preferences for an existing terminal before creating a Dispatch', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'reuse exact worker' }) + + await expect( + call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_worker', + model: 'gpt-5.6-sol' + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + // Why: `cursor` on PATH is the Cursor desktop app; passing the agent id as a // shell command opened the IDE and left a blank shell (issue #11926). it('never passes the agent id to the worker terminal as a shell command', async () => { diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts new file mode 100644 index 00000000000..d357ea57b73 --- /dev/null +++ b/src/shared/agent-session-option-agent-args.ts @@ -0,0 +1,35 @@ +export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] { + const terminator = tokens.indexOf('--') + return terminator === -1 ? tokens : tokens.slice(0, terminator) +} + +export function removeAgentArgOption( + tokens: readonly string[], + aliases: readonly string[] +): string[] { + const result: string[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + result.push(...tokens.slice(index)) + break + } + const exact = aliases.includes(token) + const matched = aliases.some( + (alias) => + token.startsWith(`${alias}=`) || + (alias.startsWith('-') && + !alias.startsWith('--') && + token.startsWith(alias) && + token.length > alias.length) + ) + if (!exact && !matched) { + result.push(token) + continue + } + if (exact && tokens[index + 1] && !tokens[index + 1].startsWith('-')) { + index += 1 + } + } + return result +} diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index ab13dda4dc5..0464fd1f131 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -3,6 +3,7 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN, @@ -10,7 +11,7 @@ import { } from './claude-model-list-probe' function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean { - return tokens.some((token) => + return agentArgOptionTokens(tokens).some((token) => flags.some( (flag) => token === flag || @@ -24,8 +25,9 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean { if (hasFlag(tokens, ['--reasoning-effort'])) { return true } - return tokens.some((token, index) => { - const previous = tokens[index - 1] + const optionTokens = agentArgOptionTokens(tokens) + return optionTokens.some((token, index) => { + const previous = optionTokens[index - 1] return ( (token.startsWith('model_reasoning_effort=') && (previous === '-c' || previous === '--config')) || @@ -36,6 +38,32 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean { }) } +function removeCodexEffortOverride(tokens: readonly string[]): string[] { + const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort']) + const result: string[] = [] + for (let index = 0; index < withoutFlag.length; index += 1) { + const token = withoutFlag[index] + if (token === '--') { + result.push(...withoutFlag.slice(index)) + break + } + const next = withoutFlag[index + 1] + if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) { + index += 1 + continue + } + if ( + token.startsWith('-cmodel_reasoning_effort=') || + token.startsWith('-c=model_reasoning_effort=') || + token.startsWith('--config=model_reasoning_effort=') + ) { + continue + } + result.push(token) + } + return result +} + const STANDARD_EFFORT_CHOICES = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, @@ -67,6 +95,7 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal apply: { launchArgs: (value) => ['--effort', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } } @@ -108,6 +137,7 @@ const CLAUDE_FAST_MODE: CatalogOption = { } export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, // Why: these ids are Claude CLI aliases that resolve to the newest model of // each family on the host's CLI (`opus` is Opus 5 on current CLIs, older // Opus on older CLIs), so pinned version labels lie on part of the fleet. @@ -143,6 +173,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['--model', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'command', build: (value) => `/model ${String(value)}`, @@ -152,6 +183,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { detectAgentInteraction: 'claude-model-switch-confirmation' } }, + unknownModelOptions: [claudeEffort(true)], listModels: { command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`, parse: parseClaudeCatalogModels @@ -181,12 +213,14 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { apply: { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, + removeAgentArgs: removeCodexEffortOverride, midSession: { kind: 'agent-picker', command: '/model' } } } } export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, // Why: Codex model access depends on auth. Keep this seed short and allow // unknown persisted ids to pass through instead of claiming a complete list. models: [ @@ -203,11 +237,13 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), // Codex accepts a model argument in its live /model command. midSession: { kind: 'command', build: (value) => `/model ${String(value)}`, pickerCommand: '/model' } - } + }, + unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts index b655c8e0637..c4f818ad549 100644 --- a/src/shared/agent-session-option-catalog-gemini-cursor.ts +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -3,9 +3,10 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' function hasModelFlag(tokens: readonly string[]): boolean { - return tokens.some( + return agentArgOptionTokens(tokens).some( (token) => token === '-m' || token === '--model' || @@ -77,6 +78,7 @@ function parseCursorModels(stdout: string): CatalogModel[] { } export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, models: [ { id: 'auto', label: 'Auto', isDefault: true, options: [] }, { @@ -93,6 +95,7 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['--model', String(value)], agentArgsOverride: hasModelFlag, + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } }, composeModelValue: (modelId, values) => { diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index a416b10913e..5475910702b 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -23,6 +23,8 @@ export type CatalogOptionApply = { /** Why: later free-form args win, so the launch record must discard any * picker value that those args may have replaced. */ agentArgsOverride?: (tokens: readonly string[]) => boolean + /** Removes conflicting defaults before a more specific launch choice is inserted. */ + removeAgentArgs?: (tokens: readonly string[]) => string[] composedIntoModel?: true midSession?: CatalogMidSessionApply } @@ -53,6 +55,10 @@ export type CatalogModel = { export type AgentSessionOptionCatalog = { models: CatalogModel[] modelApply: CatalogOptionApply + /** Opts this agent into structured per-worker launch overrides. */ + supportsWorkerLaunchPreferences?: true + /** Launch-safe options for opaque model ids that are absent from the static catalog. */ + unknownModelOptions?: CatalogOption[] composeModelValue?: (modelId: string, values: Record) => string listModels?: { command: string diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 9181039f0a7..53df967a838 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -7,10 +7,32 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +export function removeOverriddenAgentSessionArgs( + agent: AgentType, + values: Record | null | undefined, + tokens: readonly string[] +): string[] { + const catalog = getAgentSessionOptionCatalog(agent) + const modelId = typeof values?.model === 'string' ? values.model : null + if (!catalog || !values || !modelId) { + return [...tokens] + } + let result = catalog.modelApply.removeAgentArgs?.(tokens) ?? [...tokens] + const model = findCatalogModel(catalog, modelId) + const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] + for (const option of modelOptions) { + if (values[option.id] !== undefined && option.apply.removeAgentArgs) { + result = option.apply.removeAgentArgs(result) + } + } + return result +} + export function resolveAgentSessionOptionLaunch( agent: AgentType, values: Record | null | undefined, - trailingAgentArgs: readonly string[] = [] + trailingAgentArgs: readonly string[] = [], + includeCatalogDefaults = true ): ResolvedSessionOptionLaunch { const catalog = getAgentSessionOptionCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null @@ -21,11 +43,23 @@ export function resolveAgentSessionOptionLaunch( const model = findCatalogModel(catalog, modelId) const appliedValues: Record = {} const args: string[] = [] - const modelValues = model - ? Object.fromEntries( - model.options.map((option) => [option.id, values[option.id] ?? option.kind.defaultValue]) - ) - : {} + const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] + const modelValues = Object.fromEntries( + modelOptions.flatMap((option) => { + const explicitValue = values[option.id] + if (explicitValue !== undefined) { + if ( + !model && + option.kind.type === 'select' && + !option.kind.choices.some((choice) => choice.value === explicitValue) + ) { + return [] + } + return [[option.id, explicitValue]] + } + return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : [] + }) + ) const composedModelId = catalog.composeModelValue ? catalog.composeModelValue(modelId, modelValues) : modelId @@ -37,11 +71,7 @@ export function resolveAgentSessionOptionLaunch( appliedValues.model = modelId } } - if (!model) { - return { args, appliedValues } - } - - for (const option of model.options) { + for (const option of modelOptions) { const value = modelValues[option.id] if (value === undefined) { continue diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c2f005ddef1..3f39bf29e34 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -32,6 +32,8 @@ export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-c export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY = 'orchestration.federation-control-mail.v1' as const +export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY = + 'orchestration.worker-launch-preferences.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION = 2 as const export const ORCHESTRATION_CONTRACT_VERSION = 1 as const export const ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY = 'orchestration.contract.v1' as const @@ -86,6 +88,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY, + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY, ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, 'browser.screencast.v1', 'terminal.binary-stream.v1', diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index d433fc61721..3ce20571f4e 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -1,4 +1,7 @@ -import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' import type { SessionOptionValue } from './native-chat-session-options' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import { @@ -25,6 +28,7 @@ export function resolveAgentLaunchCommand(args: { shell: AgentStartupShell agentArgs?: string | null sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean isRemote?: boolean }): ResolvedAgentLaunchCommand { const override = args.cmdOverrides[args.agent] @@ -46,17 +50,60 @@ export function resolveAgentLaunchCommand(args: { const resolvedOptions = resolveAgentSessionOptionLaunch( args.agent, args.sessionOptions, - trailingTokens.tokens + args.sessionOptionsOverrideAgentArgs ? [] : trailingTokens.tokens, + !args.sessionOptionsOverrideAgentArgs ) + if (override && args.sessionOptionsOverrideAgentArgs) { + const overrideTokens = tokenizeStartupCommand(override, args.shell) + if (!overrideTokens.ok) { + return { ok: false, error: `Agent command override is invalid: ${overrideTokens.error}` } + } + const commandOverrideOptions = resolveAgentSessionOptionLaunch( + args.agent, + args.sessionOptions, + overrideTokens.tokens, + false + ) + if ( + Object.entries(resolvedOptions.appliedValues).some( + ([key, value]) => commandOverrideOptions.appliedValues[key] !== value + ) + ) { + return { + ok: false, + error: + 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + } + } + } const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ') - const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command - // Why: session flags precede the free-form suffix so the user's explicit - // repeated flag remains the final, winning occurrence. + const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command + const overrideTokens = args.sessionOptionsOverrideAgentArgs + ? insertBeforeTerminator( + removeOverriddenAgentSessionArgs(args.agent, args.sessionOptions, trailingTokens.tokens), + resolvedOptions.args + ) + : [] + const commandWithOverrides = overrideTokens.length + ? `${command} ${overrideTokens.map((token) => quoteStartupArg(token, args.shell)).join(' ')}` + : command return { ok: true, - command: suffix.suffix ? `${commandWithOptions} ${suffix.suffix}` : commandWithOptions, + command: args.sessionOptionsOverrideAgentArgs + ? commandWithOverrides + : suffix.suffix + ? `${commandWithOptions} ${suffix.suffix}` + : commandWithOptions, commandWithoutSessionOptions, appliedSessionOptions: resolvedOptions.appliedValues } } + +function insertBeforeTerminator(tokens: readonly string[], inserted: readonly string[]): string[] { + const terminator = tokens.indexOf('--') + if (terminator === -1) { + return [...tokens, ...inserted] + } + return [...tokens.slice(0, terminator), ...inserted, ...tokens.slice(terminator)] +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index af97826f530..cd47d81b971 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -4,6 +4,7 @@ import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' describe('tui agent startup session options', () => { it('emits catalog options before user arguments without recording an overridden model', () => { @@ -33,6 +34,59 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'opus' }) }) + it('lets explicit worker preferences override general agent arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '-m gpt-5.5 -c model_reasoning_effort=low' + }) + expect(plan?.launchCommand).toBe( + "codex '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high'" + ) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) + }) + + it('inserts worker preferences before an argument terminator', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--dangerously-bypass-approvals-and-sandbox -- literal' + }) + expect(plan?.launchCommand).toBe( + "codex '--dangerously-bypass-approvals-and-sandbox' '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high' '--' 'literal'" + ) + }) + + it('rejects conflicting singleton flags in an agent command override', () => { + expect( + resolveAgentLaunchCommand({ + agent: 'codex', + cmdOverrides: { codex: 'codex --profile work -m gpt-5.5' }, + platform: 'linux', + shell: 'posix', + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true + }) + ).toEqual({ + ok: false, + error: + 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + }) + }) + it('recognizes a long Codex model flag overriding the generated short flag', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 682643f7c79..27d4b40ac8d 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -36,9 +36,7 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps( - values: Record -): Pick { +function appliedSessionOptionProps(values: Record) { return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } @@ -52,6 +50,7 @@ export function buildAgentStartupPlan(args: { agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean @@ -68,6 +67,7 @@ export function buildAgentStartupPlan(args: { shell, agentArgs: usesQuery ? null : args.agentArgs, sessionOptions: args.sessionOptions, + sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs, isRemote: args.isRemote }) if (!baseCommand.ok) { From 3d6a98a834319f5ef6df0761de0b9e42d7530f51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:54:45 -0700 Subject: [PATCH 28/88] fix(native-chat): report the current Claude model from the terminal frame (#12860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): locate Claude's model row by frame structure The scraper assumed the model descriptor sits within three rows of the `Claude Code vX` line. It does not: Claude prints it near the bottom of the startup frame with the welcome art and release-notes panel in between — eight rows down at 100 columns. Narrow panes degrade the frame further, dropping the version from the title row entirely below ~70 columns and wrapping the billing tail onto its own row. Any one of those made the scrape return null, so the model picker showed no current selection at all. Search the frame from its bottom border upward for the row carrying model metadata, read only the leftmost frame cell so release-notes prose can never win, accept the frame corner as header proof when the version is gone, and tolerate the effort suffix being elided to an ellipsis. Catalog families now match as a leading word, which both survives the resolved-name suffix ("Opus 5 (1M context)") and keeps custom slugs like company/my-haiku-v2 from being claimed as haiku; an unrecognized name is reported as a custom model. Fixtures are real: captured from a live claude 2.1.220 by replaying the PTY bytes through @xterm/headless and serializing exactly as TerminalPane does. * fix(native-chat): resolve the scraped model against the host's real catalog The scraper matched the static seed while the picker lists what #12369 discovers from the host CLI, so the two spoke different id spaces. On a current CLI `list_models` returns `opus[1m]`, `sonnet`, `sonnet[1m]`, `fable` and `haiku` — no plain `opus` — while the seed only knows families. Reporting `opus` therefore selected a row the picker had to invent, dropping the host's own effort and fast-mode descriptors with it. Locating the model row correctly made this the normal case rather than a rarity, since the scrape now succeeds. Resolve against the discovered list first, falling back to the seed for aliases a host no longer lists and to the raw name for genuinely custom models. Matching requires the family to lead as a whole word and the label's remaining tokens to appear in order, so `Opus 5 (1M context)` picks `opus[1m]`, plain `Sonnet 5` keeps `sonnet` instead of being captured by the 1M-context row, and `opus-internal-v3` stays custom. Most specific label wins. The hook keeps the screen that parsed so a discovery landing after the first read re-resolves it, rather than stranding a family id once the frame has scrolled out of the buffer. * fix(native-chat): identify option-less models on narrow panes Live capture at 60 columns: a Haiku session prints a bare `Haiku 4.5` row with its billing wrapped to the next line. No middot, no effort suffix — nothing marks it as the model, so it reported nothing. Claude always closes the frame with the working directory and prints at most the descriptor plus a wrapped billing line above it, so fall back to walking up from there when no row carries descriptor metadata. The height bound is what keeps the walk from climbing into the welcome art. * test(native-chat): pin re-resolution when discovery lands after the read Covers the wiring the parser tests cannot reach: the frame is visible at mount and gone by the time the host's model list arrives, so only the cached screen can drive the second resolve. Fails against a listener that merely replaces the models. * fix(native-chat): prevent stale Claude model reports --- .../claude-terminal-session-options.test.ts | 326 ++++++++++++++++++ .../claude-terminal-session-options.ts | 217 +++++++++++- .../use-native-chat-session-options.test.ts | 178 ++++++++++ .../use-native-chat-session-options.ts | 59 +++- 4 files changed, 748 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index a41d62eeb7a..82f9280bffb 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -1,7 +1,277 @@ import { describe, expect, it } from 'vitest' import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' +// Captured from a live `claude` 2.1.220 startup frame by replaying the PTY +// bytes through @xterm/headless and calling serializeAddon.serialize({ +// scrollback: 0 }) — byte-for-byte what TerminalPane hands the scraper. The two +// widths matter: at 100 columns the frame keeps its version and prints billing +// on the model row, at 60 it drops the version and wraps billing to its own row. + +const FRESH_SESSION_100_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭───\u001b[1CClaude Code\u001b[1C\u001b[38;2;153;153;153mv2.1.220\u001b[1C\u001b[38;2;215;119;87m─────────────────────────────────────────────────────────────────────────╮\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22;1mTips for getting started\u001b[22C\u001b[22m│\r', + '│\u001b[19C\u001b[39;1mWelcome back!\u001b[18C\u001b[38;2;215;119;87;22;2m│\u001b[1C\u001b[0mRun\u001b[1C/init\u001b[1Cto\u001b[1Ccreate\u001b[1Ca\u001b[1CCLAUDE.md\u001b[1Cfile\u001b[1Cwith\u001b[1Cin…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22m─────────────────────────────────────────────\u001b[1C│\r', + "│\u001b[21C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[21C\u001b[2m│\u001b[1C\u001b[22;1mWhat's new\u001b[36C\u001b[22m│\r", + '│\u001b[21C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[20C\u001b[2m│\u001b[1C\u001b[0mBug\u001b[1Cfixes\u001b[1Cand\u001b[1Creliability\u001b[1Cimprovements\u001b[8C\u001b[38;2;215;119;87m│\r', + '│\u001b[21C ▘▘ ▝▝ \u001b[20C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1CClaude\u001b[1COpus\u001b[1C5\u001b[1C(`claude-opus-5`),\u001b[1Cnow\u001b[1Ct…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1C`sandbox.network.strictAllowlist`\u001b[1Csett…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[3C\u001b[38;2;153;153;153mFable 5 with high effort · API Usage Billing\u001b[3C\u001b[38;2;215;119;87;2m│\u001b[1C\u001b[38;2;153;153;153;22;3m/release-notes for more\u001b[23C\u001b[38;2;215;119;87;23m│\r', + '│\u001b[19C\u001b[38;2;153;153;153m/private/tmp\u001b[19C\u001b[38;2;215;119;87;2m│\u001b[47C\u001b[22m│\r', + '╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and …\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "fix typecheck errors"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker\u001b[38;2;153;153;153m · restart with CLAUDE_COD…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const FRESH_SESSION_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[17C\u001b[38;2;153;153;153mFable 5 with high effort\u001b[17C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "refactor "\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const OPTION_LESS_MODEL_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[24C\u001b[38;2;153;153;153mHaiku 4.5\u001b[25C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "how does work?"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mHaiku 4.5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const EFFORT = { + id: 'effort', + label: 'Effort', + kind: { + type: 'select' as const, + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: {} +} + describe('Claude terminal session option detection', () => { + it('reads the model from a real 100-column startup frame', () => { + // The descriptor sits eight rows under the version, below the welcome art. + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_100_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real 60-column frame that drops the version and wraps billing', () => { + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_60_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real narrow frame whose option-less model carries no metadata', () => { + // Why: `Haiku 4.5` has no effort control and its billing wrapped to the next + // row, so nothing marks the row as a model — only its position above the + // working directory does. + expect(readClaudeSessionOptionsFromTerminalScreen(OPTION_LESS_MODEL_60_COLS)).toEqual({ + model: 'haiku' + }) + }) + + it('reads an option-less model above a Windows working directory', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│Haiku 4.5│', + '│API Usage Billing│', + '│C:\\work\\repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ model: 'haiku' }) + }) + + it('reports nothing rather than release-notes prose when the model row is bare', () => { + // Why: a custom model with its billing wrapped away carries no descriptor + // metadata, so nothing in the frame identifies it. The panel shares rows + // with the art and its borders collide (`││`) — reading past them would + // make panel prose win the search and report a sentence as the model. + const screen = [ + '╭─── Claude Codev2.1.220 ───────────────────────╮', + '││Runs faster · uses fewer tokens│', + '│ ▐▛███▜▌│What‽s new│', + '│acme-frontier│', + '│API Usage Billing│', + '│/repo││', + '╰───────────────────────────────────────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('reports nothing when a partial frame only contains wrapped billing', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│API Usage Billing│', + '│/repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('does not reject a bare custom model whose name mentions billing', () => { + const screen = ['╭─ Claude Code ───╮', '│billing-model│', '│/repo│', '╰─────────────╯'].join( + '\r\n' + ) + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'billing-model' + }) + }) + + // The exact catalog `claude -p` list_models returns on CLI 2.1.220: note the + // host offers no plain `opus`, and `sonnet` and `sonnet[1m]` are distinct. + const DISCOVERED = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [EFFORT] }, + { id: 'sonnet', label: 'Sonnet', options: [EFFORT] }, + { id: 'sonnet[1m]', label: 'Sonnet 5 (1M context)', options: [EFFORT] }, + { id: 'fable', label: 'Fable', options: [EFFORT] }, + { id: 'haiku', label: 'Haiku', options: [] } + ] + + function frame(descriptor: string): string { + return `Claude Code v2.1.220\r\n${descriptor}\r\n~/repo` + } + + it('resolves to the host id the picker lists, not the seed family', () => { + // Why: `opus` is not offered on this host, so reporting it would select a + // row the picker has to invent. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 5 (1M context) with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus[1m]', effort: 'high' }) + }) + + it('prefers the most specific variant when a family also matches', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 (1M context) with low effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet[1m]', effort: 'low' }) + }) + + it('keeps the plain family when the frame names no variant', () => { + // Why: `Sonnet 5` must not be captured by the 1M-context row just because + // that row's label happens to start with the same two tokens. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 with medium effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet', effort: 'medium' }) + }) + + it('falls back to a seed family the host list no longer offers', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 4.8 with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus', effort: 'high' }) + }) + + it('still reports a custom model when the host list cannot name it', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('company/my-haiku-v2 · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'company/my-haiku-v2' }) + }) + + it('reads an effort suffix the frame truncated to an ellipsis', () => { + const screen = + 'Claude Code v2.1.220\r\n' + + 'Opus 5 (1M context) with high… · API Usage Billing\r\n' + + '~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + it('reads the current model and effort from Claude header chrome', () => { const screen = '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + @@ -63,4 +333,60 @@ describe('Claude terminal session option detection', () => { readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') ).toBeNull() }) + + it('recovers a custom model name that is not in the catalog', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mmy-custom-model with high effort · API Usage Billing\r\n' + + '~/repo' + + // Custom models have no catalog options, so effort is intentionally dropped. + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'my-custom-model' + }) + }) + + it('recovers a custom model with no effort suffix', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/internal-opus · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/internal-opus' + }) + }) + + it('keeps a custom model containing a catalog label', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/my-haiku-v2 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/my-haiku-v2' + }) + }) + + it('keeps a custom model whose name only starts with a catalog family', () => { + // Why: the family must match as a whole leading word, not a bare prefix, or + // every vendor slug built off a family name would be claimed as that model. + const screen = 'Claude Code v2.1.211\r\nopus-internal-v3 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus-internal-v3' + }) + }) + + it('ignores catalog labels outside the descriptor row', () => { + const screen = 'Claude Code v2.1.211\r\nacme-frontier · API Usage Billing\r\n~/work/Haiku' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) + + it('recovers a custom model when xterm joins the descriptor to the header', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211' + + '▝▜█████▛▘acme-frontier with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) }) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts index 7a1e5b8fbd8..3b8ad86a47a 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -1,4 +1,7 @@ -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { + getAgentSessionOptionCatalog, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { stripScrollbackAnsi } from './native-chat-scrape-fallback' @@ -11,41 +14,219 @@ const EFFORT_ID_BY_LABEL: Record = { max: 'max' } +// Why: narrow panes elide the trailing `effort` word to an ellipsis, so the +// level itself is the only part of the suffix we can rely on. +const CLAUDE_MODEL_EFFORT = + /\bwith\s+(extra high|xhigh|medium|high|low|max)(?:\s+effort\b|\s*…|\s*$)/i + +// Claude's startup frame. Chat text uses an em dash rather than these box +// characters, so a leading corner is safe proof that a row is chrome. +const FRAME_TOP = '╭' +const FRAME_BOTTOM = '╰' +const FRAME_COLUMN = '│' + +/** Rows of the visible screen, ANSI-stripped and whitespace-collapsed. */ function normalizedScreenLines(screen: string): string[] { return stripScrollbackAnsi(screen) .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) } +function isClaudeHeaderRow(line: string): boolean { + // No trailing word boundary: serialization drops the gap before the version, + // leaving `Claude Codev2.1.220`. + if (!/\bClaude Code/i.test(line)) { + return false + } + // Why: panes under ~70 columns drop the version from the frame entirely, so + // accept the frame corner as the alternative proof this is Claude's chrome. + // xterm serialization also drops cursor-positioning cells between the product + // name and version, producing `Claude Codev2.1.211`. + return /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) || line.startsWith(FRAME_TOP) +} + +/** The frame's leftmost cell on a row. Reading up to the next column border + * keeps the release-notes panel out: on rows whose left cell is empty the + * borders collide (`││`) and this yields nothing rather than the panel text. */ +function frameCell(line: string): string { + if (line.startsWith(FRAME_COLUMN)) { + const afterBorder = line.slice(FRAME_COLUMN.length) + const columnEnd = afterBorder.indexOf(FRAME_COLUMN) + return (columnEnd < 0 ? afterBorder : afterBorder.slice(0, columnEnd)).trim() + } + // Unframed buffers put the descriptor on the row directly, behind box art. + return line.replace(/^[^A-Za-z0-9]+/, '').trim() +} + +/** Whether a cell carries the metadata Claude only prints on the model row, so + * ordinary frame text is never mistaken for a model. */ +function isModelDescriptorCell(cell: string): boolean { + return cell.includes('·') || CLAUDE_MODEL_EFFORT.test(cell) +} + +function isWorkingDirectoryCell(cell: string): boolean { + return ( + cell.startsWith('/') || + cell.startsWith('~') || + /^[A-Za-z]:[\\/]/.test(cell) || + /^\\\\[^\\]/.test(cell) + ) +} + +/** + * Claude prints the model descriptor near the bottom of its startup frame, with + * the welcome art and the release-notes panel in between — it is not adjacent to + * the version row. Search the frame from the bottom up so the panel's own text + * can never win over the descriptor. + */ +function claudeModelDescriptorCell(lines: string[], headerIndex: number): string | null { + const frameBottom = lines.findIndex( + (line, index) => index > headerIndex && line.startsWith(FRAME_BOTTOM) + ) + // Why: an unframed buffer gives no bottom to bound the search, so keep the + // original tight window rather than reaching into conversation text. + const lastRow = frameBottom > 0 ? frameBottom - 1 : Math.min(headerIndex + 2, lines.length - 1) + for (let index = lastRow; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isModelDescriptorCell(cell)) { + return cell + } + } + // xterm serialization can join the descriptor onto the version row itself. + const joined = frameCell((lines[headerIndex] ?? '').replace(/^.*?\bClaude Code\s*v?[\d.]*/i, '')) + if (isModelDescriptorCell(joined)) { + return joined + } + return frameBottom > 0 ? modelRowAboveWorkingDirectory(lines, headerIndex, frameBottom) : null +} + +/** + * A model with no effort control whose billing wrapped away — `Haiku 4.5` on a + * narrow pane — leaves a bare name that no metadata identifies. Claude always + * closes the frame with the working directory, and prints at most the + * descriptor plus a wrapped billing line above it, so walk up from there. + */ +function modelRowAboveWorkingDirectory( + lines: string[], + headerIndex: number, + frameBottom: number +): string | null { + let workingDirectory = -1 + for (let index = frameBottom - 1; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isWorkingDirectoryCell(cell)) { + workingDirectory = index + break + } + } + if (workingDirectory < 0) { + return null + } + let top = workingDirectory + while (top - 1 > headerIndex && frameCell(lines[top - 1] ?? '')) { + top -= 1 + } + // Anything taller than descriptor + billing is the welcome art, not a model. + if (top >= workingDirectory || workingDirectory - top > 2) { + return null + } + const cell = frameCell(lines[top] ?? '') + return /^API Usage Billing$/i.test(cell) ? null : cell +} + +/** The displayed model name, without the effort suffix or billing tail. */ +function parseClaudeModelName(cell: string): string | null { + // split always yields a non-empty first segment for a non-empty cell. + const beforeBilling = cell.split('·')[0]! + const effort = beforeBilling.match(CLAUDE_MODEL_EFFORT) + const name = ( + effort?.index === undefined ? beforeBilling : beforeBilling.slice(0, effort.index) + ).trim() + return name || null +} + +function modelNameTokens(value: string): string[] { + return value.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? [] +} + +/** + * Whether a catalog label names the model the frame reported. The frame prints + * the resolved model ("Opus 5 (1M context)") while labels name the alias + * ("Opus (1M context)"), so the family has to lead as a whole word and the + * label's remaining tokens have to appear in order. Requiring a whole word is + * what keeps `company/my-haiku-v2` and `opus-internal-v3` out of the catalog; + * requiring the rest is what stops `Sonnet` from claiming the 1M-context row. + */ +function labelNamesReportedModel(reportedModel: string, label: string): boolean { + const labelTokens = modelNameTokens(label) + const family = labelTokens[0] + if (!family) { + return false + } + const name = reportedModel.toLowerCase() + if (name !== family && !name.startsWith(`${family} `)) { + return false + } + const nameTokens = modelNameTokens(name) + let cursor = 1 + for (const token of labelTokens.slice(1)) { + const found = nameTokens.indexOf(token, cursor) + if (found < 0) { + return false + } + cursor = found + 1 + } + return true +} + +/** The most specific catalog entry naming this model, preferring the host's + * discovered list so variants like `opus[1m]` win over the seed family. */ +function findClaudeCatalogModel( + reportedModel: string, + models: readonly CatalogModel[] | undefined +): CatalogModel | undefined { + const seeded = getAgentSessionOptionCatalog('claude')?.models ?? [] + for (const candidates of [models ?? [], seeded]) { + const matches = candidates.filter(({ label }) => labelNamesReportedModel(reportedModel, label)) + const best = matches.sort( + (left, right) => modelNameTokens(right.label).length - modelNameTokens(left.label).length + )[0] + if (best) { + return best + } + } + return undefined +} + +/** + * @param models the host's live model list. Pass the discovered catalog so the + * reported id is one the picker actually lists; omitting it falls back to the + * version-neutral seed families. + */ export function readClaudeSessionOptionsFromTerminalScreen( - screen: string | null | undefined + screen: string | null | undefined, + models?: readonly CatalogModel[] ): Record | null { if (!screen) { return null } const lines = normalizedScreenLines(screen) - const headerIndex = lines.findIndex((line) => - // xterm serialization can remove cursor-positioning cells between the - // product name and version, producing `Claude Codev2.1.211`. - /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) - ) + const headerIndex = lines.findIndex(isClaudeHeaderRow) if (headerIndex < 0) { return null } - // Why: only Claude's fixed header rows describe live state; conversation - // text and old command confirmations elsewhere in the buffer can be stale. - const header = lines.slice(headerIndex, headerIndex + 3).join(' ') - const catalog = getAgentSessionOptionCatalog('claude') - const model = [...(catalog?.models ?? [])] - .sort((left, right) => right.label.length - left.label.length) - .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) - if (!model) { + const descriptorCell = claudeModelDescriptorCell(lines, headerIndex) + const reportedModel = descriptorCell ? parseClaudeModelName(descriptorCell) : null + if (!reportedModel) { return null } + const model = findClaudeCatalogModel(reportedModel, models) + if (!model) { + // A custom model carries no catalog options, so no effort is reported. + return { model: reportedModel } + } const result: Record = { model: model.id } - const effortLabel = header.match( - /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i - )?.[1] + const effortLabel = descriptorCell?.match(CLAUDE_MODEL_EFFORT)?.[1] const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined if (effort && model.options.some((option) => option.id === 'effort')) { result.effort = effort diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts new file mode 100644 index 00000000000..a24d1b8f87f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' + +const discoverModels = vi.fn<() => Promise>() + +vi.mock('./native-chat-session-option-discovery', () => ({ + resolveNativeChatModelDiscoveryContext: () => ({ hostKey: 'host', runtime: null }), + discoverNativeChatCatalogModels: () => discoverModels() +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ settings: {}, updateSettings: async () => undefined }) + }) +})) + +import { useNativeChatSessionOptions } from './use-native-chat-session-options' + +// A 1M-context Opus session: the frame names the resolved model while the +// host's discovered catalog names the alias. +const CLAUDE_SCREEN = + 'Claude Code v2.1.220\r\nOpus 5 (1M context) with high effort · API Usage Billing\r\n~/repo' + +const DISCOVERED: CatalogModel[] = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'haiku', label: 'Haiku', options: [] } +] + +function modelDescriptor(snapshot: { id: string; kind: unknown }[]): { + currentValue?: string + choices: { value: string }[] +} { + const model = snapshot.find((descriptor) => descriptor.id === 'model') + return model?.kind as { currentValue?: string; choices: { value: string }[] } +} + +describe('useNativeChatSessionOptions model reporting', () => { + beforeEach(() => { + clearNativeChatModelEnrichmentForTests() + discoverModels.mockReset() + Object.defineProperty(window, 'api', { configurable: true, value: undefined }) + }) + + it('re-resolves the reported model against models discovered after the read', async () => { + // Why: discovery is async, so the first scrape can only reach the seed. If + // the reported id were left at the family the picker would show a row it + // had to invent — and by then the frame may have scrolled out of the buffer, + // so re-reading the screen is not an option. + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + + // Stable identity, and the frame scrolls out of the buffer before discovery + // lands — so nothing but the cached screen can drive the re-resolution. + let frameVisible = true + const readTerminalScreen = (): string | null => + frameVisible ? CLAUDE_SCREEN : 'conversation has scrolled past the frame' + const dispatchCommand = vi.fn() + + const { result } = renderHook(() => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId: 'pty-1', + dispatchCommand, + readTerminalScreen + }) + ) + + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + frameVisible = false + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus[1m]') + ) + // The invented family row is gone: every choice is one the host listed. + expect(modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)).toEqual([ + 'opus[1m]', + 'haiku' + ]) + }) + + it('does not re-resolve a late snapshot from the previous pty', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + let resolveOldSnapshot: (snapshot: { data: string; alternateScreen: false }) => void = () => {} + const oldSnapshot = new Promise<{ data: string; alternateScreen: false }>((resolve) => { + resolveOldSnapshot = resolve + }) + const getMainBufferSnapshot = vi + .fn() + .mockReturnValueOnce(oldSnapshot) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-old' } } + ) + + rerender({ targetPtyId: 'pty-new' }) + resolveOldSnapshot({ data: CLAUDE_SCREEN, alternateScreen: false }) + await Promise.resolve() + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) + + it('clears a previously reported screen when the target pty changes', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const getMainBufferSnapshot = vi + .fn() + .mockResolvedValueOnce({ data: CLAUDE_SCREEN, alternateScreen: false }) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-reset', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-reported' } } + ) + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + rerender({ targetPtyId: 'pty-empty' }) + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index b0dfe6503fe..a722a1ffc98 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' @@ -36,6 +36,9 @@ export function useNativeChatSessionOptions(args: { } { const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = args + // The screen text that last parsed into reported values, so a later model + // discovery can re-resolve it against the host's real ids. + const reportedScreenRef = useRef(null) const discoveryContext = useMemo( () => resolveNativeChatModelDiscoveryContext(terminalTabId), [terminalTabId] @@ -47,8 +50,16 @@ export function useNativeChatSessionOptions(args: { return null } const scopeKey = targetPtyId ?? terminalTabId + const discoveredModels = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null const reportedValues = - agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + agent === 'claude' + ? readClaudeSessionOptionsFromTerminalScreen( + readTerminalScreen?.(), + discoveredModels ?? undefined + ) + : null let settingsWrite = Promise.resolve() return createNativeChatPtySessionOptions({ agent, @@ -57,12 +68,7 @@ export function useNativeChatSessionOptions(args: { // Why: the catalog seed carries version-neutral family labels, so it is // safe on every host while the once-per-host probe runs or after it fails // — without it the whole picker would pop in late or never appear. - ...(discoveryContext - ? { - initialModels: - readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined - } - : {}), + ...(discoveryContext ? { initialModels: discoveredModels ?? undefined } : {}), mode: targetPtyId ? 'live' : 'draft', reportedValues, dispatchCommand, @@ -105,6 +111,7 @@ export function useNativeChatSessionOptions(args: { return } let cancelled = false + reportedScreenRef.current = null const reportCurrentValues = async (): Promise => { let authoritativeScreen: string | null = null if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { @@ -119,18 +126,33 @@ export function useNativeChatSessionOptions(args: { // The mounted renderer buffer remains a transport-neutral fallback. } } - const reportedValues = - readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? - readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) - if (!cancelled && reportedValues) { + const models = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null + for (const screen of [authoritativeScreen, readTerminalScreen?.() ?? null]) { + const reportedValues = readClaudeSessionOptionsFromTerminalScreen( + screen, + models ?? undefined + ) + if (!reportedValues) { + continue + } + // Why: discovery can land after this read. Keeping the screen that + // parsed lets it re-resolve against the host's real ids later, when the + // frame itself may have already scrolled out of the buffer. + if (cancelled) { + return + } + reportedScreenRef.current = screen surface.reportSessionOptions(reportedValues) + return } } void reportCurrentValues() return () => { cancelled = true } - }, [agent, readTerminalScreen, surface, targetPtyId]) + }, [agent, discoveryContext, readTerminalScreen, surface, targetPtyId]) useEffect(() => { if (!surface || !discoveryContext) { @@ -139,7 +161,16 @@ export function useNativeChatSessionOptions(args: { const unsubscribe = subscribeNativeChatEnrichedModels( agent, discoveryContext.hostKey, - (models) => surface.replaceModels(models) + (models) => { + surface.replaceModels(models) + const screen = agent === 'claude' ? reportedScreenRef.current : null + const reportedValues = screen + ? readClaudeSessionOptionsFromTerminalScreen(screen, models) + : null + if (reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } ) ensureNativeChatModelEnrichment({ agent, From cd8c66551af780d01a74a8f73a6f089dd3d844a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:06:36 -0700 Subject: [PATCH 29/88] fix(agent-hooks): resumed Claude Code session gets its sidebar agent row at SessionStart (STA-3386) (#12859) * fix(agent-hooks): give resumed Claude sessions a sidebar row at SessionStart (STA-3386) Claude's hook set never registered SessionStart and normalizeClaudeEvent dropped it at ingest, so a resumed session that idled produced zero hook traffic and earned no sidebar agent row until the first prompt. - Register SessionStart in CLAUDE_EVENTS (local + remote installs). - Map lead SessionStart (startup/resume/clear) to an idle 'done' row, resetting stale roster/task/cron/tool/prompt state like the Codex path; compact restarts and child-attributed SessionStart stay dropped. - Thread hookEventName through the agent-status IPC payload so the completion coordinator can tell a session connect from a turn result; a SessionStart 'done' no longer raises agent-task-complete. * fix(agent-hooks): mark SessionStart rows as session boundaries, not completions (STA-3386) Review follow-up: represent the idle connect as a first-class sessionBoundary flag on the status payload instead of gating one renderer consumer on hookEventName. - sessionBoundary rides AgentStatusPayload/AgentStatusEntry (done-only, clamped like interrupted); drops the hookEventName IPC threading. - Completion-reactive consumers ignore session boundaries: the completion coordinator (task-complete notifications), automation dispatch observers (a connecting agent no longer completes the run and closes its tab), activity unread counts, and the dashboard finished timestamp; the status slice keeps boundaries out of stateHistory and preserves the flag across done->done repaints. - SessionStart sources are allowlisted (startup/resume/clear) so compact restarts or unknown sources fail closed mid-turn. - A live SessionStart now un-retires a reusable pane like a fresh prompt, so resume-in-reused-pane earns its row too. * fix(agent-hooks): keep session-boundary dones out of teardown and completion history (STA-3386) Review round 2: - A boundary done no longer deletes the pane's launch-config registry entry, so a resumed idle TUI keeps its registered-launch-agent identity evidence. - A boundary landing on a REAL done pushes that completion into stateHistory so the finished timestamp and unread badge survive a resume//clear right after a finish. - The done->done flag carry yields to turn evidence (assistant message or changed prompt) so a genuine completion can never be suppressed. - Star-nag value-moment observer and the server's OSC-equivalence dedupe now discriminate the flag. * fix(agent-hooks): keep a displaced completion unread in the sidebar badge (STA-3386) Review round 3: sidebar-badge mode counts only the live entry, so a session boundary landing on an unacknowledged completion silently dropped the sidebar badge while the agent-events count kept it. Count the displaced completion from history for boundary rows, and pin the behavior with countActivityUnread tests. * fix(agent-hooks): prevent SessionStart completion side effects (STA-3386) * fix(agent-hooks): preserve SessionStart through renderer IPC (STA-3386) --- src/cli/runtime/websocket-transport.test.ts | 6 +- src/main/agent-hooks/server.test.ts | 30 ++++++ src/main/agent-hooks/server.ts | 12 ++- src/main/claude/hook-service.test.ts | 3 + src/main/claude/hook-settings.ts | 3 + ...ession-tab-agent-status-projection.test.ts | 60 +++++++++++ .../session-tab-agent-status-projection.ts | 33 ++++++ src/main/runtime/rpc/methods/session-tabs.ts | 49 +++++++-- .../activity/ActivityPrototypePage.test.ts | 19 ++++ .../activity/ActivityPrototypePage.tsx | 3 +- .../activity/useActivityUnreadCount.test.ts | 66 ++++++++++++ .../activity/useActivityUnreadCount.ts | 16 ++- .../automations/AutomationsPage.tsx | 23 ++-- ...automation-run-completion-evidence.test.ts | 88 +++++++++++++++ .../automation-run-completion-evidence.ts | 38 +++++++ .../dashboard/DashboardAgentRow.tsx | 19 +--- .../agent-finished-timestamp.test.ts | 39 +++++++ .../dashboard/agent-finished-timestamp.ts | 4 +- .../editor/ReviewNotesSendMenuContent.tsx | 14 +-- .../sidebar/smart-attention.test.ts | 18 ++++ .../src/components/sidebar/smart-attention.ts | 26 ++++- .../StarNagAgentValueMomentObserver.tsx | 3 + .../agent-completion-coordinator.ts | 7 ++ ...gent-hook-completion-notifications.test.ts | 37 +++++++ .../hooks/useAutomationDispatchEvents.test.ts | 29 +++++ .../src/hooks/useAutomationDispatchEvents.ts | 7 +- src/renderer/src/hooks/useIpcEvents.test.ts | 97 +++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 1 + .../src/store/slices/agent-status.test.ts | 100 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 36 ++++++- .../src/web/web-runtime-client.test.ts | 10 +- src/renderer/src/web/web-runtime-client.ts | 10 +- src/shared/agent-hook-listener.test.ts | 90 ++++++++++++++++ src/shared/agent-hook-listener.ts | 43 +++++++- src/shared/agent-status-types.test.ts | 14 +++ src/shared/agent-status-types.ts | 9 ++ src/shared/protocol-version.ts | 3 + src/shared/remote-runtime-client.test.ts | 10 +- src/shared/remote-runtime-client.ts | 15 ++- .../remote-runtime-request-connection.test.ts | 15 ++- .../remote-runtime-request-connection.ts | 10 +- ...-runtime-shared-control-connection.test.ts | 10 +- ...te-runtime-shared-control-frame-handler.ts | 10 +- 43 files changed, 1046 insertions(+), 89 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts create mode 100644 src/renderer/src/components/activity/useActivityUnreadCount.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.ts create mode 100644 src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 2764a239021..7cba4d1cdc1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -17,6 +17,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY @@ -61,7 +62,10 @@ describe('CLI remote WebSocket transport', () => { expect(response.result.runtimeId).toBe('runtime-ws-1') expect(runtime.authFrames).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) }) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e7..c1870d35a7c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + it('accepts a live remote prompt but not replay after launch authority retires', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d..013b90d7143 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload( a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted + a.interrupted === b.interrupted && + // Why: a session-boundary done must never be deduped against a cached real done — + // the flag has to reach receivers deterministically (STA-3386). + a.sessionBoundary === b.sessionBoundary ) } @@ -996,7 +999,12 @@ export class AgentHookServer { return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. - if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + // A live SessionStart proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + if ( + (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && + event.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) return 'restart' diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index cd31d5577f5..0d390c3a9e3 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -378,6 +378,9 @@ describe('ClaudeHookService.installRemote', () => { // Code rejects unknown shapes silently and the agent-hooks pipeline // goes dark. for (const event of [ + // Why: SessionStart is the only signal a resumed/idle session ever emits; + // without it the sidebar row waits for the first prompt (STA-3386). + 'SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index b13c98ed6a9..de932276545 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -28,6 +28,9 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { } export const CLAUDE_EVENTS = [ + // Why: SessionStart is the only event a resumed/idle session emits before the + // first prompt; without it the sidebar row can't exist until the user types (STA-3386). + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: OpenClaude skips normal Stop hooks after API/model errors and emits diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts new file mode 100644 index 00000000000..45f6613e354 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' + +function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapshot { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + title: 'Claude', + parentTabId: 'tab-1', + leafId: 'leaf-1', + isActive: true, + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 100, + stateStartedAt: 100, + paneKey: 'tab-1:leaf-1', + stateHistory: [], + sessionBoundary + } + } + ] + } +} + +describe('projectSessionTabAgentStatus', () => { + it('withholds session boundaries from legacy paired clients', () => { + const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) + + expect(projected.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('publishes session boundaries to clients that negotiated them', () => { + const snapshot = makeSnapshot(true) + + expect( + projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY]) + ).toBe(snapshot) + }) + + it('does not alter local, mobile, or real-completion projections', () => { + const localBoundary = makeSnapshot(true) + const mobileBoundary = makeSnapshot(true) + const runtimeCompletion = makeSnapshot(false) + + expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary) + expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary) + expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts new file mode 100644 index 00000000000..125e083db4f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -0,0 +1,33 @@ +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../../../shared/runtime-types' + +type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot + +export function projectSessionTabAgentStatus( + payload: TPayload, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: readonly RuntimeCapability[] | undefined +): TPayload { + // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. + if ( + clientKind !== 'runtime' || + clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY) + ) { + return payload + } + + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) { + return tab + } + changed = true + const { agentStatus: _boundary, ...legacyTab } = tab + return legacyTab + }) + return changed ? ({ ...payload, tabs } as TPayload) : payload +} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 14716e0bf17..397753d609a 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -12,19 +12,26 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId }) => - runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => + projectSessionTabAgentStatus( + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, + clientCapabilities + ) }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId }) => ({ - snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) + handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ + snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) }) }), defineMethod({ @@ -128,7 +135,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, - handler: async (params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let subscribedWorktree: string | null = null let unsubscribe = (): void => {} let closed = false @@ -156,7 +167,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshot', ...initial }) + emit({ + type: 'snapshot', + ...projectSessionTabAgentStatus(initial, clientKind, clientCapabilities) + }) initialized = true if (closed) { return @@ -164,7 +178,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { if (snapshot.worktree === subscribedWorktree) { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) } }, pairedDeviceId) if (closed) { @@ -193,7 +210,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + _params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let unsubscribe = (): void => {} let closed = false // Why: initial listAll errors should return one RPC error, not a leaked @@ -227,14 +248,22 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshots', snapshots }) + emit({ + type: 'snapshots', + snapshots: snapshots.map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) + }) initialized = true if (closed) { return } unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) }, pairedDeviceId) } }), diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index b9e03ecc492..05aec890947 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -261,6 +261,25 @@ describe('buildActivityEvents', () => { expect(threads[0].events[0].entry.prompt).toBe('First prompt') }) + it('does not turn a session boundary into an Agent finished event', () => { + const result = makeActivityResult({ + entries: { + [PANE_KEY]: { + ...makeWorkingEntryWithoutHistory(), + state: 'done', + prompt: '', + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + } + } + }) + + // Why: the displaced real completion stays visible; the idle SessionStart does not add a second finish. + expect(result.events).toHaveLength(1) + expect(result.events[0]).toMatchObject({ state: 'done', timestamp: 1_000 }) + expect(result.events[0].entry.prompt).toBe('Real turn') + }) + it('does not keep showing a stale live agent as running', () => { const result = makeActivityResult({ entries: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 8e732ee044c..8c8058fbeeb 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -590,7 +590,8 @@ function appendActivityEventsForEntry(args: { }) } - if (!isActivityEventState(args.entry.state)) { + // Why: SessionStart creates an idle row, not an "Agent finished" activity event (STA-3386). + if (!isActivityEventState(args.entry.state) || args.entry.sessionBoundary === true) { return } appendActivityEvent({ diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts new file mode 100644 index 00000000000..5c28a132762 --- /dev/null +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { countActivityUnread } from './useActivityUnreadCount' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function makeEntry(overrides: Partial): AgentStatusEntry { + return { + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + paneKey: PANE, + agentType: 'claude', + stateHistory: [], + ...overrides + } +} + +function makeSource(entry: AgentStatusEntry, ackAt = 0) { + return { + acknowledgedAgentsByPaneKey: { [PANE]: ackAt }, + agentStatusByPaneKey: { [PANE]: entry }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + worktreesByRepo: {} + } +} + +describe('countActivityUnread session-boundary rows (STA-3386)', () => { + it('does not count a session-boundary done as unread in either mode', () => { + const source = makeSource(makeEntry({ sessionBoundary: true })) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('keeps counting a real completion displaced into history by a session boundary', () => { + // Why: agent finished (unacknowledged), then the user resumed the session — the + // boundary row replaces the live done but the finish must stay unread in both badges. + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }) + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source, 'agent-events')).toBe(1) + }) + + it('stops counting the displaced completion once acknowledged', () => { + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }), + 1_500 + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + const source = makeSource(makeEntry({})) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index b496f9146cd..91b51c4455e 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -60,8 +60,22 @@ export function countActivityUnread( } } } - if (isUnreadAgentState(entry.state) && ackAt < entry.stateStartedAt) { + // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. + // History never contains a boundary, but it DOES keep the real completion a boundary + // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the + // history loop above — must still count that displaced completion or the badge silently + // drops an unacknowledged finish the moment its session is resumed. + if ( + isUnreadAgentState(entry.state) && + entry.sessionBoundary !== true && + ackAt < entry.stateStartedAt + ) { count += 1 + } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { + const displaced = entry.stateHistory.at(-1) + if (displaced && isUnreadAgentState(displaced.state) && ackAt < displaced.startedAt) { + count += 1 + } } } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 97f15d844a6..e8a33f4dcd0 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -46,12 +46,12 @@ import { waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { - automationRunMatchesPaneKey, buildAutomationRunOpenLayout, canOpenAutomationRunOpenTarget, getAutomationRunOpenTabId, resolveAutomationRunOpenTarget } from './automation-run-open-target' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import { AutomationEditorDialog, @@ -903,21 +903,12 @@ export default function AutomationsPage(): React.JSX.Element { if (dispatchedAt === null) { return false } - const liveDone = Object.entries(agentStatusByPaneKey).some( - ([paneKey, entry]) => - automationRunMatchesPaneKey(run, paneKey) && - entry.state === 'done' && - entry.updatedAt >= dispatchedAt - ) - if (liveDone) { - return true - } - return Object.entries(retainedAgentsByPaneKey).some( - ([paneKey, retained]) => - automationRunMatchesPaneKey(run, paneKey) && - retained.entry.state === 'done' && - retained.entry.updatedAt >= dispatchedAt - ) + return hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey + }) }) if (completedRuns.length === 0) { return diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts new file mode 100644 index 00000000000..f46426337a8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function run(): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'dispatched', + trigger: 'manual', + workspaceId: 'wt-1', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-1', + terminalPaneKey: PANE, + terminalPtyId: 'pty-1', + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1_000, + dispatchedAt: 1_000, + createdAt: 1_000 + } +} + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE, + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function retained(status: AgentStatusEntry): RetainedAgentEntry { + return { + entry: status, + worktreeId: 'wt-1', + tab: { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_000 + }, + agentType: 'claude', + startedAt: status.stateStartedAt + } +} + +describe('automation run completion evidence (STA-3386)', () => { + it('ignores live and retained session-boundary done rows', () => { + const boundary = entry({ sessionBoundary: true }) + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: boundary }, + retainedAgentsByPaneKey: { [PANE]: retained(boundary) } + }) + ).toBe(false) + }) + + it('accepts a real done after dispatch', () => { + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: entry() }, + retainedAgentsByPaneKey: {} + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.ts new file mode 100644 index 00000000000..26c0b0ffc93 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.ts @@ -0,0 +1,38 @@ +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { automationRunMatchesPaneKey } from './automation-run-open-target' + +function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean { + return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt +} + +export function hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey +}: { + run: AutomationRun + dispatchedAt: number + agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record +}): boolean { + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(entry, dispatchedAt) + ) { + return true + } + } + for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(retained.entry, dispatchedAt) + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 60724a27dcf..460c251b98b 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -12,6 +12,7 @@ import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' +import { lastEnteredDoneAt } from './agent-finished-timestamp' // Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes. function asDotState(state: AgentStatusState | 'idle'): AgentDotState { @@ -43,24 +44,6 @@ function formatTimeAgo(ts: number, now: number): string { return `${days}d ago` } -// Why: use stateStartedAt (not updatedAt, which drifts on within-state pings) for the true done-transition time. -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - // Why: idle subagents are alive-but-idle (persist between turns); don't label them as done. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { - return null - } - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = entry.stateHistory.length - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function stateDotTooltipLabel(agent: DashboardAgentRowData, dotState: AgentDotState): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts new file mode 100644 index 00000000000..92df277ef9d --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { lastEnteredDoneAt } from './agent-finished-timestamp' + +function row(entry: AgentStatusEntry) { + return { rowSource: 'live' as const, state: entry.state, entry } +} + +function doneEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +describe('lastEnteredDoneAt session boundaries (STA-3386)', () => { + it('does not report an idle SessionStart as a completed turn', () => { + expect(lastEnteredDoneAt(row(doneEntry({ sessionBoundary: true })))).toBeNull() + }) + + it('falls through to a real completion displaced into history', () => { + expect( + lastEnteredDoneAt( + row( + doneEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + }) + ) + ) + ).toBe(1_000) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 2c81cec0be2..6449e570fe1 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -15,7 +15,9 @@ export function lastEnteredDoneAt( return null } const entry = agent.entry - if (entry.state === 'done') { + // Why: a session-boundary done means the session connected idle (STA-3386) — nothing + // finished; fall through to history (which never records session boundaries). + if (entry.state === 'done' && entry.sessionBoundary !== true) { return entry.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 25b55305c06..adb0c3d1ed8 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -28,6 +28,7 @@ import { import { track } from '@/lib/telemetry' import { useNow } from '@/components/dashboard/useNow' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -322,19 +323,6 @@ function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): str return startedAt > 0 ? `${formatTimeAgo(startedAt, now)}` : null } -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = entry.stateHistory.length - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function formatTimeAgo(ts: number, now: number): string { const delta = now - ts if (delta < 60_000) { diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index d32192d36a5..2c4324938cd 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -61,6 +61,7 @@ function makeEntry(overrides: Partial & { paneKey: string }): terminalTitle: overrides.terminalTitle, stateHistory: overrides.stateHistory ?? [], interrupted: overrides.interrupted, + sessionBoundary: overrides.sessionBoundary, restoredUnconfirmed: overrides.restoredUnconfirmed } } @@ -177,6 +178,23 @@ describe('resolveAttention', () => { expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE) }) + it('treats a session boundary as idle unless it displaced a real completion', () => { + const boundary = makeEntry({ + paneKey: 't:1', + state: 'done', + sessionBoundary: true, + stateStartedAt: NOW - 1_000, + updatedAt: NOW - 500 + }) + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual(IDLE) + + boundary.stateHistory = [makeHistory('done', NOW - 90_000)] + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual({ + cls: 2, + attentionTimestamp: NOW - 90_000 + }) + }) + it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 6c102a41405..760fd9d16ca 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -98,6 +98,21 @@ export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): return max > 0 ? max : null } +function mostRecentCompletedTurnInHistory(history: AgentStateHistoryEntry[]): number | null { + let max = 0 + for (const entry of history) { + if ( + entry.state === 'done' && + entry.interrupted !== true && + Number.isFinite(entry.startedAt) && + entry.startedAt > max + ) { + max = entry.startedAt + } + } + return max > 0 ? max : null +} + /** * One pane's contribution to a worktree's attention class. Fresh hook entries win; hookless * panes fall back to the title heuristic (design doc Edge case 9). Authority is per-pane, not per-worktree. @@ -142,7 +157,16 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten continue } cls = 2 - ts = entry.stateStartedAt + if (entry.sessionBoundary) { + // Why: idle connect is not new attention; only preserve a real completion it displaced. + const completedAt = mostRecentCompletedTurnInHistory(entry.stateHistory) + if (completedAt === null) { + continue + } + ts = completedAt + } else { + ts = entry.stateStartedAt + } } else { // working cls = 3 diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 1fd3099323d..42f6145b1f3 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -33,6 +33,9 @@ function hasSuccessfulDoneTransition( previousEntry && previousEntry.state !== 'done' && entry.state === 'done' && + // Why: a session-boundary done is an idle connect (STA-3386), not a value moment — + // a stale working row + resume would otherwise nag on launch. + entry.sessionBoundary !== true && !entry.interrupted && hasMeaningfulPrompt(entry) ) { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index b948cf43da6..b3059ca6390 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -810,6 +810,13 @@ export function createAgentCompletionCoordinator( dispatchAttention(payload) return } + if (payload.state === 'done' && payload.sessionBoundary === true) { + // Why: a session-boundary 'done' is an idle session connecting (resume/launch/clear, + // STA-3386), not a completed turn — record the activity/evidence above but never + // complete. Pending timers stay untouched: a quiet-window done from a real prior + // turn must still fire. + return + } if (isCompletionHookState(payload.state)) { // Why: the turn is ending, so a debounced attention from an earlier pause must not fire after completion. clearPendingCodexAttention() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 228af0058eb..4803731fb70 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -421,6 +421,43 @@ describe('agent hook completion notifications', () => { ) }) + it('does not fire a completion notification for a session-boundary done row', async () => { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + // Why: Claude SessionStart lands as a sessionBoundary 'done' so a resumed session gets + // its sidebar row while idle (STA-3386) — connecting to a session is not completing a turn. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + stateStartedAt: 1_700_000_000_000 + } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // Why: the resumed session's next real turn must still notify normally. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('working'), agentType: 'claude' } + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('done'), agentType: 'claude', stateStartedAt: 1_700_000_020_000 } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + it('does not notify twice when the same done hook snapshot replays after activation', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 160496c3be8..d49778b3a05 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -539,6 +539,35 @@ describe('useAutomationDispatchEvents setup launch', () => { }) }) + it('ignores a session-boundary done so a connecting agent cannot complete the run (STA-3386)', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string; sessionBoundary?: boolean }) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + // Why: Claude fires SessionStart (a sessionBoundary done) at launch, before the argv + // prompt submits — treating it as run completion would close the tab on an empty run. + launchArgs.onAgentStatus?.({ state: 'done', sessionBoundary: true }) + await Promise.resolve() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + }) + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { let launchArgs: { onAgentStatus?: (payload: { state: string }) => void diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index d543d639ddf..09009d40011 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -416,6 +416,10 @@ export function useAutomationDispatchEvents(): void { } if ( entry.state === 'done' && + // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart + // fires at launch, before the argv prompt submits) — completing here would + // close the tab and record an empty run result. + entry.sessionBoundary !== true && (!options?.requireWorkingAfterStart || sawWorkingAfterStart) ) { latestAssistantMessage = @@ -528,7 +532,8 @@ export function useAutomationDispatchEvents(): void { onAgentStatus: (payload) => { latestAssistantMessage = payload.lastAssistantMessage?.trim() || latestAssistantMessage - if (payload.state !== 'done') { + // Why: session-boundary done = launch connect, not run completion (see observeAgentStatus). + if (payload.state !== 'done' || payload.sessionBoundary === true) { return } handleAgentDone() diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c13cf20057a..e2d831c9da4 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -16,6 +16,12 @@ import { loadIpcEventsHarness, type HarnessStoreState } from './ipc-events-test-harness' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../store/slices/store-test-helpers' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' import type { TuiAgent } from '../../../shared/types' @@ -4949,6 +4955,7 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput?: string lastAssistantMessage?: string interrupted?: boolean + sessionBoundary?: boolean terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } @@ -7842,6 +7849,96 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('preserves a SessionStart boundary from snapshot IPC without completion side effects', async () => { + const now = Date.now() + const refreshGitHubForWorktreeIfStale = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [{ ...TEST_REPO, connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] + }, + tabsByWorktree: { + 'wt-1': [ + makeTab({ + id: 'tab-future', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Claude' + }) + ] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + refreshGitHubForWorktreeIfStale + }) + store + .getState() + .setAgentStatus( + FUTURE_PANE_KEY, + { state: 'working', prompt: 'resumed task', agentType: 'claude' }, + 'Claude', + { updatedAt: now - 2, stateStartedAt: now - 2 }, + { tabId: 'tab-future', worktreeId: 'wt-1' } + ) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot: () => + Promise.resolve([ + { + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + receivedAt: now, + stateStartedAt: now + } + ]), + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect(store.getState().agentStatusByPaneKey[FUTURE_PANE_KEY]).toMatchObject({ + state: 'done', + sessionBoundary: true + }) + }) + await Promise.resolve() + + expect(refreshGitHubForWorktreeIfStale).not.toHaveBeenCalled() + expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + }) + it('queues replayed startup snapshots until the pane hydrates', async () => { const setAgentStatus = vi.fn() const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index bed3e4e86c8..5b719c72420 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3100,6 +3100,7 @@ export function useIpcEvents(): void { interactivePrompt: data.interactivePrompt, lastAssistantMessage: data.lastAssistantMessage, interrupted: data.interrupted, + sessionBoundary: data.sessionBoundary, // Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted. subagents: data.subagents }) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index b6b2976e26b..6965ca8bbb2 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -1399,3 +1399,103 @@ describe('agent status retention + prefix sweep', () => { expect(retained['tab-b:0']).toBeUndefined() }) }) + +describe('session-boundary done semantics (STA-3386)', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + + it('keeps the launch config registry entry alive across a session-boundary done', () => { + const store = createTestStore() + store.setState({ + agentLaunchConfigByPaneKey: { + [PANE]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' }, + launchToken: 'token-1' + } + } + } as unknown as Partial) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + // Why: the session just CONNECTED — the registered-launch-agent identity must survive + // or an idle resumed TUI loses its pane identity evidence at startup. + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeDefined() + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeUndefined() + }) + + it('pushes a real completion into history when a session boundary lands on it', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.sessionBoundary).toBe(true) + // Why: the finished timestamp and unread badge fall through to history for boundary + // entries — losing the real done here erases an unacknowledged completion. + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(true) + }) + + it('never records a session boundary itself in state history', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(false) + }) + + it('carries the flag across metadata-less done repaints but yields to turn evidence', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + // Why: OSC 9999 repaints and reconnect replays re-deliver a metadata-less done. + store.getState().setAgentStatus(PANE, { state: 'done', prompt: '', agentType: 'claude' }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBe(true) + + // Why: an assistant message proves a REAL completion — the flag must not suppress it. + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index fcf39188136..271ab70829a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1739,8 +1739,22 @@ export const createAgentStatusSlice: StateCreator { expect(JSON.parse(auth!)).toEqual({ type: 'e2ee_auth', deviceToken: 'token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) client.close() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 3ed33e18887..a51d93ac0d3 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -14,7 +14,10 @@ import { publicKeyFromBase64, publicKeyToBase64 } from './web-e2ee' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' import { withReconnectJitter } from '../../../shared/reconnect-jitter' @@ -456,7 +459,10 @@ export class WebRuntimeClient { this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 73c1aae3a6d..edab2cb88f6 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1369,6 +1369,96 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.lastAssistantMessage).toBeUndefined() }) + it('maps Claude SessionStart to an idle done row so a resumed session earns its sidebar row before the first prompt', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: '44444444-4444-4444-8444-444444444444' + } + }, + 'production' + ) + + // Why: 'working' would show a phantom spinner on an idle TUI; a session-boundary + // 'done' renders the row idle, which is the truth at SessionStart. + expect(event?.payload).toMatchObject({ + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + expect(event?.payload.interrupted).toBeUndefined() + expect(event?.hookEventName).toBe('SessionStart') + // Why: SessionStart carries resume identity, so in-app resume works before any prompt. + expect(event?.providerSession).toMatchObject({ + key: 'session_id', + id: '44444444-4444-4444-8444-444444444444' + }) + }) + + it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' }) + normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' } + }) + normalizeAndAccept(state, 'claude', { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }) + + const event = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'startup' + }) + + // Why: a new process owns the pane; stale prompt/tool/children must not survive + // into the fresh session's idle row or gate it back up to 'working'. + expect(event?.payload.state).toBe('done') + expect(event?.payload.prompt).toBe('') + expect(event?.payload.toolName).toBeUndefined() + expect(event?.payload.subagents).toBeUndefined() + }) + + it('keeps the running Claude turn when SessionStart comes from a compact restart or a child session', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + + const compacted = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } }, + 'production' + ) + // Why: unknown/missing sources fail closed — only startup/resume/clear are idle boundaries. + const unknownSource = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart' } }, + 'production' + ) + const child = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', source: 'startup', agent_id: 'agent-7' } + }, + 'production' + ) + const stopped = normalizeAndAccept(state, 'claude', { hook_event_name: 'Stop' }) + + // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a + // child-attributed SessionStart must not flip the lead's live turn to an idle row. + expect(compacted).toBeNull() + expect(unknownSource).toBeNull() + expect(child).toBeNull() + expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' }) + expect(stopped?.payload.sessionBoundary).toBeUndefined() + }) + it('normalizes Devin documented lifecycle events', () => { const started = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876..9e133aa683a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2405,9 +2405,11 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. switch (source) { case 'claude': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. - // falls through + // Why: SessionStart lands an idle row (STA-3386) and must also drop stale + // tool/prompt caches left by the pane's previous session. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'kimi': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -2739,6 +2741,35 @@ function normalizeClaudeEvent( ) { return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } + if (eventName === 'SessionStart') { + // Why: SessionStart is the only signal a resumed session emits before its first prompt + // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom + // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary + // flag keeps completion-reactive consumers (notifications, automation runs) out of it. + const sessionStartSource = hookPayload['source'] + if ( + eventAgentId !== undefined || + (sessionStartSource !== 'startup' && + sessionStartSource !== 'resume' && + sessionStartSource !== 'clear') + ) { + // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown + // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's + // live turn to an idle row. + return null + } + // Why: a new process owns the pane; stale children/tasks/crons must not gate the + // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: 'done', + updateToolSnapshot: true, + sessionBoundary: true + }) + } const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' @@ -2890,7 +2921,12 @@ function buildClaudeStatusPayload( promptText: string, paneKey: string, hookPayload: Record, - options: { stateName: AgentStatusState; updateToolSnapshot: boolean; interrupted?: boolean } + options: { + stateName: AgentStatusState + updateToolSnapshot: boolean + interrupted?: boolean + sessionBoundary?: boolean + } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. const snapshot = options.updateToolSnapshot @@ -2913,6 +2949,7 @@ function buildClaudeStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, interrupted: options.interrupted, + sessionBoundary: options.sessionBoundary, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) }) } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 6059950b708..1dea3dc70fe 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -421,6 +421,20 @@ Fix dispatch fallback preview for normalized status prompts` } }) + it('preserves sessionBoundary=true only on done (stale-signal suppression like interrupted)', () => { + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":true}')!.sessionBoundary + ).toBe(true) + for (const state of ['working', 'blocked', 'waiting'] as const) { + const result = parseAgentStatusPayload(`{"state":"${state}","sessionBoundary":true}`) + expect(result!.sessionBoundary).toBeUndefined() + } + // Why: parser uses `=== true`, so truthy sentinels don't count. + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":"true"}')!.sessionBoundary + ).toBeUndefined() + }) + it('requires strict boolean true for interrupted (rejects truthy non-boolean)', () => { // Why: parser uses `=== true`, so truthy string/number sentinels don't count. expect( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0d902ecb0fc..123a1bf5eb7 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -126,6 +126,8 @@ export type AgentStatusEntry = { /** True when this `done` was reached via interrupt, not normal completion * (agent-reported or Orca's guarded fallback). Undefined otherwise. */ interrupted?: boolean + /** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */ + sessionBoundary?: boolean /** Orchestration dispatch context for panes spawned by another agent. * Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */ orchestration?: AgentStatusOrchestrationContext @@ -170,6 +172,11 @@ export type AgentStatusPayload = { interactivePrompt?: string lastAssistantMessage?: string interrupted?: boolean + /** True when this `done` marks a session boundary (connect/resume/clear landing idle, + * e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to + * completions (notifications, automation runs, unread badges, finished timestamps) + * must ignore it. Only meaningful on `done`. */ + sessionBoundary?: boolean /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] } @@ -202,6 +209,7 @@ export function pickParsedAgentStatusPayload( ? { lastAssistantMessage: row.lastAssistantMessage } : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), + ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) } } @@ -401,6 +409,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | ), // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, + sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, subagents: normalizeSubagentsField(obj.subagents) } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3f39bf29e34..c967c66822b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -72,6 +72,8 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = + 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const @@ -108,6 +110,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 43a2a982d73..9ead47f4067 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -13,7 +13,10 @@ import { } from './e2ee-crypto' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client' import { MAX_TIMER_DELAY_MS } from './timer-delay' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' const servers: WebSocketServer[] = [] @@ -69,7 +72,10 @@ describe('subscribeRemoteRuntimeRequest', () => { await expect(server.nextAuth).resolves.toEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) const bytes = new Uint8Array([1, 2, 3]) expect(subscription.sendBinary(bytes)).toBe(true) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index bd424cd0852..f147ed4e7bb 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -22,7 +22,10 @@ import { type RuntimeRpcResponse } from './runtime-rpc-envelope' import type { RuntimeStatus } from './runtime-types' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' // Re-export so existing value importers of `RemoteRuntimeClientError` are // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. @@ -134,7 +137,10 @@ async function sendRemoteRuntimeRequestOnSocket( const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) const pendingRequest = { preparedRequest: prepareRemoteRuntimeRequest(new Map(), () => @@ -507,7 +513,10 @@ export async function subscribeRemoteRuntimeRequest( const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return await new Promise((resolve, reject) => { const keyPair = generateKeyPair() diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 9bd5ff253d3..7f8f4e69871 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -11,7 +11,10 @@ import { publicKeyToBase64 } from './e2ee-crypto' import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' type TestServer = { wss: WebSocketServer @@ -58,7 +61,10 @@ describe('RemoteRuntimeRequestConnection', () => { expect(server.connectionCount()).toBe(1) expect(server.auths).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) expect(server.requests).toMatchObject([ @@ -106,7 +112,10 @@ async function createServer(): Promise { expect(auth).toEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index 8ff1f246c8e..f79c5cdd136 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -30,7 +30,10 @@ import { type RemoteRuntimeRequestReadyWaiter } from './remote-runtime-request-ready-waiters' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' @@ -207,7 +210,10 @@ export class RemoteRuntimeRequestConnection { serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }), sharedKey ) diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 43ec4473c2a..66a57a92e52 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -20,7 +20,10 @@ import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepa import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' const TEST_PROJECT_PATH = path.join('tmp', 'project') @@ -62,7 +65,10 @@ describe('RemoteRuntimeSharedControlConnection', () => { expect(server.auths).toContainEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) expect(server.requests.map((request) => request.method)).toEqual([ 'worktree.ps', diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 3bf3c5cdcee..96a3e1084ad 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -1,6 +1,9 @@ import { parseAuthenticatedFrame, parseReadyFrame } from './remote-runtime-request-frames' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch' import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol' import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' @@ -38,7 +41,10 @@ export function handleSharedControlTextFrame(args: { args.sendEncrypted({ type: 'e2ee_auth', deviceToken: args.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return } From 89f00658d256b90895921d150c02eae446538bfb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:08:40 -0700 Subject: [PATCH 30/88] fix(mobile): bound home host auto-connect fanout (#11642) * fix(mobile): bound home host auto-connect fanout * fix(mobile): clarify bounded host connection state * fix(mobile): close unused settings host clients * fix(mobile): close released host clients * fix(mobile): sync host release policy in effect * fix(mobile): preserve focused host client ownership * test(mobile): cover manual Home client release * Fix React Doctor array type check --- mobile/app/index.tsx | 24 +- mobile/app/terminal-settings.tsx | 4 +- mobile/app/voice-settings.tsx | 15 +- mobile/src/transport/client-context.test.ts | 178 ++++ mobile/src/transport/client-context.tsx | 190 +++-- .../transport/home-host-auto-connect.test.ts | 56 ++ .../src/transport/home-host-auto-connect.ts | 24 + .../host-client-acquisition-count.ts | 9 + .../transport/host-client-context-state.ts | 72 ++ .../settings-host-client-connections.ts | 19 + .../settings-host-client-lifecycle.test.ts | 776 ++++++++++++++++++ mobile/src/transport/use-all-host-clients.ts | 142 +++- 12 files changed, 1372 insertions(+), 137 deletions(-) create mode 100644 mobile/src/transport/home-host-auto-connect.test.ts create mode 100644 mobile/src/transport/home-host-auto-connect.ts create mode 100644 mobile/src/transport/host-client-acquisition-count.ts create mode 100644 mobile/src/transport/host-client-context-state.ts create mode 100644 mobile/src/transport/settings-host-client-connections.ts create mode 100644 mobile/src/transport/settings-host-client-lifecycle.test.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 1a4681565e3..70da22348e6 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -27,6 +27,10 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context' import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from '../src/transport/home-host-auto-connect' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import { @@ -247,7 +251,11 @@ export default function HomeScreen() { const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const allClients = useAllHostClients(hostIds) + const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) + const allClients = useAllHostClients(hostIds, { + autoConnectHostIds, + closeUnusedOnRelease: true + }) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] @@ -745,7 +753,11 @@ export default function HomeScreen() { } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { - const state = hostStates[item.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + item.id, + hostStates[item.id], + autoConnectHostIds + ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const verdict = classifyConnection({ @@ -948,7 +960,13 @@ export default function HomeScreen() { message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined} actions={getHostListActionSheetActions({ host: actionTarget, - state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected', + state: actionTarget + ? resolveHomeHostConnectionState( + actionTarget.id, + hostStates[actionTarget.id], + autoConnectHostIds + ) + : 'disconnected', hasEverConnected: actionTarget ? (hostLastConnected[actionTarget.id] ?? null) != null : false, diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e6aa691c76e..e55819aa22a 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' @@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients } = useFocusedSettingsHostClients(hostIds) const hostClientsById = useMemo( () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), [hostClients] diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a09d964b388..8648a1d38e5 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -9,12 +9,12 @@ import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' @@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) // Voice dictation runs on the paired desktop, so pick the first connected host. const client: RpcClient | null = useMemo( () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, @@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element { const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const [routeFocused, setRouteFocused] = useState(false) - - useFocusEffect( - useCallback(() => { - setRouteFocused(true) - return () => setRouteFocused(false) - }, []) - ) - const refresh = useCallback(async (): Promise => { if (!client) { return false diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index e7c00008e05..b08899f5333 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({ })) import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' +import { useAllHostClients } from './use-all-host-clients' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -386,3 +388,179 @@ describe('useHostClient', () => { expect(connectMock).not.toHaveBeenCalled() }) }) + +describe('useAllHostClients', () => { + it('only opens the requested startup subset', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledOnce() + expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('keeps startup connection fanout constant for a large saved-host list', async () => { + const hosts = Array.from({ length: 1_000 }, (_, index) => ({ + ...HOST, + id: `host-${index}`, + name: `Host ${index}`, + lastConnected: index + })) + const hostIds = hosts.map((host) => host.id) + const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue(hosts) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients(hostIds, { autoConnectHostIds }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(3) + expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ + 'host-999', + 'host-998', + 'host-997' + ]) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('closes a demoted Home client when the recent-host set rotates', async () => { + const hosts = [ + { ...HOST, id: 'host-a', lastConnected: 4 }, + { ...HOST, id: 'host-b', lastConnected: 3 }, + { ...HOST, id: 'host-c', lastConnected: 2 }, + { ...HOST, id: 'host-d', lastConnected: 1 } + ] + const clients = new Map() + connectMock.mockImplementation((profile: typeof HOST) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(hosts) + + let activeHostIds: string[] = [] + let renderer: ReactTestRenderer | null = null + function Probe({ profiles }: { profiles: typeof hosts }): null { + const hostIds = profiles.map((host) => host.id) + activeHostIds = useAllHostClients(hostIds, { + autoConnectHostIds: selectHomeAutoConnectHostIds(profiles), + closeUnusedOnRelease: true + }).map(({ hostId }) => hostId) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts })) + ) + await Promise.resolve() + }) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c']) + + const rotatedHosts = hosts.map((host) => + host.id === 'host-d' ? { ...host, lastConnected: 5 } : host + ) + await act(async () => { + renderer?.update( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts })) + ) + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d']) + expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() + expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains connect-all behavior when no startup subset is provided', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id]) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('allows an excluded host to connect manually', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + + let reconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id], { autoConnectHostIds: [] }) + reconnect = useForceReconnect() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(connectMock).not.toHaveBeenCalled() + if (!reconnect) { + throw new Error('reconnect harness did not initialize') + } + await act(async () => { + await reconnect?.(HOST.id) + }) + expect(connectMock).toHaveBeenCalledOnce() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) +}) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 8c00a11b2be..18ee0b7782a 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' +import { decrementPendingAcquisition } from './host-client-acquisition-count' +import { + clientActivePath, + listHostClients, + notifyAllHostListeners, + notifyHostStateListeners, + primeHostProfiles, + subscribeAllHostListener, + subscribeHostStateListener, + type CloseEntryOptions +} from './host-client-context-state' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' type StoreEntry = { @@ -29,6 +40,8 @@ type StoreEntry = { export type RpcClientContextValue = { acquire: (hostId: string, host?: HostProfile) => RpcClient | null release: (hostId: string) => void + releaseAndCloseIfUnused: (hostId: string) => void + closeIfUnused: (hostId: string) => void forceReconnect: (hostId: string) => Promise closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState @@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup. const pendingOpensRef = useRef(new HostClientOpenRegistry()) + const pendingAcquisitionsRef = useRef>(new Map()) // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start. const primedHostsRef = useRef>(new Map()) - function notifyHostState(hostId: string, state: ConnectionState) { - const set = stateListenersRef.current.get(hostId) - if (!set) { - return - } - for (const listener of set) { - listener(state) - } - } + const notifyHostState = (hostId: string, state: ConnectionState) => + notifyHostStateListeners(stateListenersRef.current, hostId, state) + const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current) - function notifyAllHosts() { - for (const listener of allHostsListenersRef.current) { - listener() - } - } - - const closeEntry = useCallback((hostId: string) => { - pendingOpensRef.current.cancel(hostId) - primedHostsRef.current.delete(hostId) + const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => { const entry = storeRef.current.get(hostId) + const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 + pendingOpensRef.current.cancel(hostId) + if (options.preserveAcquisitions && acquisitionCount > 0) { + pendingAcquisitionsRef.current.set(hostId, acquisitionCount) + } else { + pendingAcquisitionsRef.current.delete(hostId) + } + if (options.forgetPrimedHost) { + primedHostsRef.current.delete(hostId) + } entry?.unsubState() storeRef.current.delete(hostId) entry?.client.close() @@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyAllHosts() }, []) + const closeHost = useCallback( + (hostId: string) => { + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true }) + }, + [closeEntry] + ) + const openEntry = useCallback(async (hostId: string): Promise => { const existing = pendingOpensRef.current.getActivePromise(hostId) if (existing) { @@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const entry: StoreEntry = { client, state: client.getState(), - refCount: 0, + refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0, unsubState } + pendingAcquisitionsRef.current.delete(hostId) storeRef.current.set(hostId, entry) notifyHostState(hostId, entry.state) notifyAllHosts() @@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { existing.refCount += 1 return existing.client } + pendingAcquisitionsRef.current.set( + hostId, + (pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1 + ) // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes. - void openEntry(hostId).then((entry) => { - if (!entry) { - return - } - entry.refCount += 1 - }) + void openEntry(hostId) return null }, [openEntry] ) - const primeHosts = useCallback((hosts: HostProfile[]) => { - for (const host of hosts) { - primedHostsRef.current.set(host.id, host) - } - }, []) + const primeHosts = useCallback( + (hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts), + [] + ) // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded. const release = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) - if (!entry) { + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) return } - entry.refCount = Math.max(0, entry.refCount - 1) + decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) }, []) + const releaseAndCloseIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) + if (entry.refCount === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + return + } + if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + + const closeIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + const pendingCount = pendingAcquisitionsRef.current.get(hostId) + const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null + if (!entry && pendingCount === undefined && !hasPendingOpen) { + return + } + if ((entry?.refCount ?? pendingCount ?? 0) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count. - const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 - const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + // Why: ownership survives explicit close/re-pair while observers never become synthetic owners. + const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 if (entry) { entry.unsubState() entry.client.close() storeRef.current.delete(hostId) } + if (savedRefCount > 0) { + pendingAcquisitionsRef.current.set( + hostId, + Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0) + ) + } // Why: Retry must read amber for the whole reopen, not grey-then-amber. notifyHostState(hostId, 'connecting') - const fresh = await openEntry(hostId) - if (fresh) { - fresh.refCount = savedRefCount - } + await openEntry(hostId) }, [openEntry] ) @@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { }, []) const subscribeHostState = useCallback( - (hostId: string, listener: (state: ConnectionState) => void) => { - let set = stateListenersRef.current.get(hostId) - if (!set) { - set = new Set() - stateListenersRef.current.set(hostId, set) - } - set.add(listener) - return () => { - const s = stateListenersRef.current.get(hostId) - if (!s) { - return - } - s.delete(listener) - if (s.size === 0) { - stateListenersRef.current.delete(hostId) - } - } - }, + (hostId: string, listener: (state: ConnectionState) => void) => + subscribeHostStateListener(stateListenersRef.current, hostId, listener), [] ) - const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { - const out: Array<{ hostId: string; client: RpcClient }> = [] - for (const [hostId, entry] of storeRef.current) { - out.push({ hostId, client: entry.client }) - } - return out - }, []) + const getAllClients = useCallback(() => listHostClients(storeRef.current), []) - const subscribeAllHosts = useCallback((listener: () => void) => { - allHostsListenersRef.current.add(listener) - return () => { - allHostsListenersRef.current.delete(listener) - } - }, []) + const subscribeAllHosts = useCallback( + (listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener), + [] + ) // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const store = storeRef.current return () => { pendingOpensRef.current.cancelAll() + pendingAcquisitionsRef.current.clear() for (const [hostId] of store) { - closeEntry(hostId) + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false }) } } }, []) @@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { () => ({ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeHost: closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void { const ctx = useRpcClientContext() return ctx.primeHosts } - -function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { - const logical = client as Partial | undefined - if (typeof logical?.getActivePath !== 'function') { - return 'lan' - } - // Why: mid-migration the active path still names the session being replaced; the - // pending one is what the user is actually waiting on (F5). - return logical.getPendingPath?.() ?? logical.getActivePath() -} diff --git a/mobile/src/transport/home-host-auto-connect.test.ts b/mobile/src/transport/home-host-auto-connect.test.ts new file mode 100644 index 00000000000..ecf04934809 --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { HostProfile } from './types' +import { + HOME_AUTO_CONNECT_LIMIT, + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from './home-host-auto-connect' + +function host(id: string, lastConnected: number, credentials = true): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}`, + deviceToken: credentials ? `token-${id}` : '', + publicKeyB64: credentials ? `key-${id}` : '', + lastConnected + } +} + +describe('home host auto-connect', () => { + it('limits startup connections to the most recently used credentialed hosts', () => { + const hosts = [ + host('old', 1), + host('newest', 5), + host('second', 4), + host('third', 3), + host('fourth', 2), + host('missing-credentials', 6, false) + ] + + expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third']) + expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT) + }) + + it('does not mutate the host card order', () => { + const hosts = [host('old', 1), host('new', 2)] + + selectHomeAutoConnectHostIds(hosts) + + expect(hosts.map((item) => item.id)).toEqual(['old', 'new']) + }) + + it('only presents hosts in the startup subset as connecting before clients open', () => { + const autoConnectHostIds = ['recent'] + + expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe( + 'connecting' + ) + expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe( + 'disconnected' + ) + expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe( + 'connected' + ) + }) +}) diff --git a/mobile/src/transport/home-host-auto-connect.ts b/mobile/src/transport/home-host-auto-connect.ts new file mode 100644 index 00000000000..995d653a0df --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.ts @@ -0,0 +1,24 @@ +import type { ConnectionState, HostProfile } from './types' + +export const HOME_AUTO_CONNECT_LIMIT = 3 + +export function selectHomeAutoConnectHostIds( + hosts: readonly HostProfile[], + limit = HOME_AUTO_CONNECT_LIMIT +): string[] { + return [...hosts] + .filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0) + .sort( + (left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id) + ) + .slice(0, Math.max(0, limit)) + .map((host) => host.id) +} + +export function resolveHomeHostConnectionState( + hostId: string, + state: ConnectionState | undefined, + autoConnectHostIds: readonly string[] +): ConnectionState { + return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected') +} diff --git a/mobile/src/transport/host-client-acquisition-count.ts b/mobile/src/transport/host-client-acquisition-count.ts new file mode 100644 index 00000000000..1a1123e5c8b --- /dev/null +++ b/mobile/src/transport/host-client-acquisition-count.ts @@ -0,0 +1,9 @@ +export function decrementPendingAcquisition(pending: Map, hostId: string): number { + const next = Math.max(0, (pending.get(hostId) ?? 0) - 1) + if (next === 0) { + pending.delete(hostId) + } else { + pending.set(hostId, next) + } + return next +} diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts new file mode 100644 index 00000000000..e0f7243baa8 --- /dev/null +++ b/mobile/src/transport/host-client-context-state.ts @@ -0,0 +1,72 @@ +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +export type CloseEntryOptions = { + forgetPrimedHost: boolean + preserveAcquisitions: boolean +} + +export function notifyHostStateListeners( + listeners: Map void>>, + hostId: string, + state: ConnectionState +): void { + for (const listener of listeners.get(hostId) ?? []) { + listener(state) + } +} + +export function notifyAllHostListeners(listeners: Set<() => void>): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeHostStateListener( + listeners: Map void>>, + hostId: string, + listener: (state: ConnectionState) => void +): () => void { + let hostListeners = listeners.get(hostId) + if (!hostListeners) { + hostListeners = new Set() + listeners.set(hostId, hostListeners) + } + hostListeners.add(listener) + return () => { + hostListeners.delete(listener) + if (hostListeners.size === 0) { + listeners.delete(hostId) + } + } +} + +export function subscribeAllHostListener( + listeners: Set<() => void>, + listener: () => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function listHostClients( + entries: ReadonlyMap +): { hostId: string; client: RpcClient }[] { + return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client })) +} + +export function primeHostProfiles(cache: Map, hosts: HostProfile[]): void { + for (const host of hosts) { + cache.set(host.id, host) + } +} + +export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { + const logical = client as Partial | undefined + if (typeof logical?.getActivePath !== 'function') { + return 'lan' + } + // Why: during migration the pending path is what the user is waiting on. + return logical.getPendingPath?.() ?? logical.getActivePath() +} diff --git a/mobile/src/transport/settings-host-client-connections.ts b/mobile/src/transport/settings-host-client-connections.ts new file mode 100644 index 00000000000..b2341471d2b --- /dev/null +++ b/mobile/src/transport/settings-host-client-connections.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { useAllHostClients } from './use-all-host-clients' + +export function useFocusedSettingsHostClients(hostIds: string[]) { + const [focused, setFocused] = useState(false) + + useFocusEffect( + useCallback(() => { + setFocused(true) + return () => setFocused(false) + }, []) + ) + + const clients = useAllHostClients(focused ? hostIds : [], { + closeUnusedOnRelease: true + }) + return { clients, focused } +} diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts new file mode 100644 index 00000000000..7a23c86ac71 --- /dev/null +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -0,0 +1,776 @@ +import { createElement, Fragment, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAllHostClients } from './use-all-host-clients' +import { useFocusedSettingsHostClients } from './settings-host-client-connections' +import { + RpcClientProvider, + useHostClient, + usePrimeHosts, + useRpcClientContext, + type RpcClientContextValue +} from './client-context' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() +const routeFocus = vi.hoisted(() => ({ + effect: null as null | (() => void | (() => void)) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + routeFocus.effect = effect + } +})) + +vi.mock('./host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./host-store', () => ({ + loadHosts: () => loadHostsMock() +})) +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +type FakeClient = RpcClient & { + closeMock: ReturnType +} + +function makeFakeClient(initialState: ConnectionState): FakeClient { + const listeners = new Set<(state: ConnectionState) => void>() + const closeMock = vi.fn() + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => initialState, + getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0), + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + notifyForeground: vi.fn(), + close: closeMock, + closeMock + } as FakeClient +} + +function host(id: string, lastConnected: number, extra: Partial = {}): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}.internal:8787`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected, + ...extra + } +} + +const HOSTS = [ + host('direct-recent', 50), + host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }), + host('ssh-provider-recent', 30), + host('folder-workspace-host', 20), + host('offline-host', 10) +] +const HOST_IDS = HOSTS.map((profile) => profile.id) +const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS) + +let context: RpcClientContextValue | null = null + +function ContextProbe(): null { + context = useRpcClientContext() + return null +} + +function HomeProbe(): null { + useAllHostClients(HOST_IDS, { + autoConnectHostIds: HOME_HOST_IDS, + closeUnusedOnRelease: true + }) + const primeHosts = usePrimeHosts() + useEffect(() => { + primeHosts(HOSTS) + }, [primeHosts]) + return null +} + +function SettingsProbe(): null { + useAllHostClients(HOST_IDS, { + closeUnusedOnRelease: true + }) + return null +} + +function FocusedSettingsProbe(): null { + useFocusedSettingsHostClients(HOST_IDS) + return null +} + +function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null { + useAllHostClients(hostIds, { closeUnusedOnRelease: true }) + return null +} + +function DetailProbe({ hostId }: { hostId: string }): null { + useHostClient(hostId) + return null +} + +type Screen = 'empty' | 'home' | 'settings' + +function TestApp({ + screen, + detailHostId +}: { + screen: Screen + detailHostId?: string +}): React.JSX.Element { + const screenProbe = + screen === 'home' + ? createElement(HomeProbe) + : screen === 'settings' + ? createElement(SettingsProbe) + : null + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null, + screenProbe + ) + ) +} + +function suppressRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderScreen(screen: Screen, detailHostId?: string): Promise { + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('settings lifecycle harness did not render') + } + return renderer +} + +async function navigate( + renderer: ReactTestRenderer, + screen: Screen, + detailHostId?: string +): Promise { + await act(async () => { + renderer.update(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeHostIds(): string[] { + if (!context) { + throw new Error('client context was not captured') + } + return context + .getAllClients() + .map(({ hostId }) => hostId) + .sort() +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + context = null + routeFocus.effect = null + connectMock.mockReset() + loadHostsMock.mockReset() +}) + +describe('settings host client lifecycle', () => { + it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('navigation stack harness did not render') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(3) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled() + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce() + } + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)).toHaveLength(2) + expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe( + true + ) + } + + act(() => renderer.unmount()) + }) + + it('leaves no zero-reference clients after repeated settings navigation', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const renderer = await renderScreen('settings') + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(1) + expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce() + } + + await navigate(renderer, 'settings') + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(2) + expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + } + + act(() => renderer.unmount()) + }) + + it('reconciles host-list changes without restarting retained or shared clients', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const retainedHostId = 'direct-recent' + const sharedHostId = 'relay-recent' + const removedHostId = 'folder-workspace-host' + const addedHostId = 'offline-host' + function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: sharedHostId }), + createElement(DynamicSettingsProbe, { hostIds: settingsHostIds }) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('host-list lifecycle harness did not render') + } + + await act(async () => { + renderer?.update( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, addedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort()) + expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('does not close a settings client still held by an active consumer', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const detailHostId = 'folder-workspace-host' + const renderer = await renderScreen('settings', detailHostId) + await navigate(renderer, 'home', detailHostId) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('keeps a reconnect alive when another consumer remains after settings leaves', async () => { + const retryHost = host('ssh-retry-host', 1) + let resolveInitial: ((hosts: HostProfile[]) => void) | null = null + let resolveRetry: ((hosts: HostProfile[]) => void) | null = null + const initialLookup = new Promise((resolve) => { + resolveInitial = resolve + }) + const retryLookup = new Promise((resolve) => { + resolveRetry = resolve + }) + loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup) + const initialClient = makeFakeClient('connected') + const retryClient = makeFakeClient('reconnecting') + connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient) + + function RetrySettingsProbe(): null { + useAllHostClients([retryHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: retryHost.id }), + settingsVisible ? createElement(RetrySettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveInitial || !resolveRetry) { + throw new Error('retry lifecycle harness did not initialize') + } + await act(async () => { + resolveInitial?.([retryHost]) + await initialLookup + }) + if (!context) { + throw new Error('client context was not captured') + } + + const reconnect = context.forceReconnect(retryHost.id) + expect(initialClient.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false }))) + await act(async () => { + resolveRetry?.([retryHost]) + await retryLookup + await reconnect + }) + + expect(activeHostIds()).toEqual([retryHost.id]) + expect(retryClient.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(retryClient.closeMock).toHaveBeenCalledOnce() + }) + + it('cancels a released open without cancelling a rapid replacement acquisition', async () => { + const settingsOnlyHost = host('settings-only-offline', 0, { + deviceToken: '', + publicKeyB64: '' + }) + let resolveFirst: ((hosts: HostProfile[]) => void) | null = null + let resolveSecond: ((hosts: HostProfile[]) => void) | null = null + const firstLookup = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondLookup = new Promise((resolve) => { + resolveSecond = resolve + }) + loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup) + const client = makeFakeClient('reconnecting') + connectMock.mockReturnValue(client) + + function PendingSettingsProbe(): null { + useAllHostClients([settingsOnlyHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function PendingApp({ visible }: { visible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + visible ? createElement(PendingSettingsProbe) : null + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveFirst || !resolveSecond) { + throw new Error('pending settings harness did not initialize') + } + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + act(() => renderer?.update(createElement(PendingApp, { visible: true }))) + await act(async () => { + resolveFirst?.([settingsOnlyHost]) + await firstLookup + }) + expect(connectMock).not.toHaveBeenCalled() + + await act(async () => { + resolveSecond?.([settingsOnlyHost]) + await secondLookup + }) + expect(connectMock).toHaveBeenCalledOnce() + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + expect(client.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.unmount()) + }) + + it('preserves Home ownership when re-pairing replaces a selected host client', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const replacedHostId = HOME_HOST_IDS[0]! + + function ReplacementApp({ + detailVisible, + settingsVisible + }: { + detailVisible: boolean + settingsVisible: boolean + }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null, + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('replacement lifecycle harness did not initialize') + } + + const originalClient = clients.get(replacedHostId)?.[0] + act(() => context?.closeHost(replacedHostId)) + expect(originalClient?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: true, settingsVisible: false }) + ) + await Promise.resolve() + await Promise.resolve() + }) + const replacementClient = clients.get(replacedHostId)?.[1] + expect(replacementClient).toBeDefined() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: true }) + ) + await Promise.resolve() + await Promise.resolve() + }) + act(() => + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + ) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(replacementClient?.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(replacementClient?.closeMock).toHaveBeenCalledOnce() + }) + + it('releases a manual host after Home demotes or stops tracking it', async () => { + const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' }) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient('reconnecting') + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([manualHost]) + + function ManualHomeProbe({ selected }: { selected: boolean }): null { + useAllHostClients([manualHost.id], { + autoConnectHostIds: selected ? [manualHost.id] : [], + closeUnusedOnRelease: true + }) + return null + } + function ManualApp({ + selected, + homeVisible = true + }: { + selected: boolean + homeVisible?: boolean + }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + homeVisible ? createElement(ManualHomeProbe, { selected }) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('manual connection harness did not initialize') + } + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: true })) + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: false })) + await Promise.resolve() + }) + + expect(activeHostIds()).toEqual([]) + expect(clients[0]?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + expect(clients).toHaveLength(2) + + act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false }))) + expect(activeHostIds()).toEqual([]) + expect(clients[1]?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + }) + + it('releases settings-only clients on blur while shared owners stay mounted', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const detailHostId = 'folder-workspace-host' + + function FocusStack(): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + createElement(DetailProbe, { hostId: detailHostId }), + createElement(FocusedSettingsProbe) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !routeFocus.effect) { + throw new Error('settings focus harness did not initialize') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + + let blur: (() => void) | undefined + await act(async () => { + const cleanup = routeFocus.effect?.() + if (typeof cleanup === 'function') { + blur = cleanup + } + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await act(async () => { + blur?.() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + for (const hostId of [...HOME_HOST_IDS, detailHostId]) { + expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled() + } + expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + }) +}) diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index a1e429b45b0..1000916113b 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -1,56 +1,126 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' import { useRpcClientContext } from './client-context' -// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]) { +type UseAllHostClientsOptions = { + autoConnectHostIds?: readonly string[] + closeUnusedOnRelease?: boolean +} + +export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) { const ctx = useRpcClientContext() - // Stable key so we don't tear down on every render of the array. - const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds + const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false + const key = useMemo( + () => + [ + [...hostIds].sort().join(','), + [...autoConnectHostIds].sort().join(','), + closeUnusedOnRelease ? 'close' : 'keep' + ].join('|'), + [autoConnectHostIds, closeUnusedOnRelease, hostIds] + ) const [tick, setTick] = useState(0) + const acquiredHostIdsRef = useRef>(new Set()) + const hostUnsubscribesRef = useRef void>>(new Map()) + const closeUnusedRef = useRef(closeUnusedOnRelease) useEffect(() => { - if (hostIds.length === 0) { - return - } - for (const id of hostIds) { - ctx.acquire(id) - } - const unsubs: (() => void)[] = [] - for (const id of hostIds) { - unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) - } - unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + closeUnusedRef.current = closeUnusedOnRelease + }, [closeUnusedOnRelease]) + + useEffect(() => { + const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1)) return () => { - for (const u of unsubs) { - u() + unsubscribeAllHosts() + const trackedHostIds = [...hostUnsubscribesRef.current.keys()] + const acquiredHostIds = new Set(acquiredHostIdsRef.current) + for (const unsubscribe of hostUnsubscribesRef.current.values()) { + unsubscribe() } - for (const id of hostIds) { - ctx.release(id) + hostUnsubscribesRef.current.clear() + for (const id of acquiredHostIds) { + if (closeUnusedRef.current) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + if (closeUnusedRef.current) { + for (const id of trackedHostIds) { + if (!acquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current.clear() + } + }, [ctx]) + + useEffect(() => { + const trackedHostIds = new Set(hostIds) + const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id))) + const removedTrackedHostIds: string[] = [] + + for (const [id, unsubscribe] of hostUnsubscribesRef.current) { + if (!trackedHostIds.has(id)) { + unsubscribe() + hostUnsubscribesRef.current.delete(id) + removedTrackedHostIds.push(id) } } - }, [key]) + for (const id of trackedHostIds) { + if (!hostUnsubscribesRef.current.has(id)) { + hostUnsubscribesRef.current.set( + id, + ctx.subscribeHostState(id, () => setTick((value) => value + 1)) + ) + } + } + + for (const id of acquiredHostIdsRef.current) { + if (!nextAcquiredHostIds.has(id)) { + if (closeUnusedOnRelease) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + } + for (const id of nextAcquiredHostIds) { + if (!acquiredHostIdsRef.current.has(id)) { + ctx.acquire(id) + } + } + if (closeUnusedOnRelease) { + for (const id of removedTrackedHostIds) { + ctx.closeIfUnused(id) + } + for (const id of trackedHostIds) { + if (!nextAcquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current = nextAcquiredHostIds + }, [ctx, key]) return useMemo(() => { - const out: { + const clientsByHostId = new Map( + ctx.getAllClients().map((entry) => [entry.hostId, entry.client]) + ) + return hostIds.flatMap<{ hostId: string client: RpcClient state: ConnectionState path: MobileConnectionPath - }[] = [] - for (const id of hostIds) { - const all = ctx.getAllClients().find((entry) => entry.hostId === id) - if (all) { - out.push({ - hostId: id, - client: all.client, - state: ctx.getState(id), - path: ctx.getActivePath(id) - }) - } - } - return out - }, [key, tick]) + }>((hostId) => { + const client = clientsByHostId.get(hostId) + return client + ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] + : [] + }) + }, [ctx, hostIds, tick]) } From c2da0e47f94ca79948f881f2d1c1a8be8a7e77ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:14:18 -0700 Subject: [PATCH 31/88] fix(computer): deliver macOS coordinate clicks via the HID event tap (STA-3433) (#12839) Mouse events posted with CGEventPostToPid reach the target app with no window association, so AppKit never routes the press to a view: hover states fire but the control is never activated, and the mouseUp is dropped outright when posted back-to-back. Post click events to the HID event tap instead (as keyboard synthesis already does), pace them, and stamp mouseEventClickState so multi-clicks register. Synthetic clicks now also report verification unverified/synthetic_input from the helper itself, matching the other synthetic actions. --- .../computer-use-modifier-safety.test.mjs | 5 +- .../Sources/OrcaComputerUseMacOS/main.swift | 44 ++++++++--- .../SyntheticMouseClickDelivery.swift | 40 ++++++++++ .../SyntheticMouseClickDeliveryTests.swift | 43 ++++++++++ tests/e2e/computer-mac.e2e.ts | 78 +++++++++++++++++++ 5 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index f39015796a9..32fdd94e938 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -28,7 +28,10 @@ describe('computer-use modifier safety', () => { ) expect(mouseInput).toContain('event.flags = flags') - expect(clickInput.match(/flags: flags/gu)).toHaveLength(3) + // Every click event flows through the shared delivery plan and carries + // the modifier flags on the mouse event itself. + expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)') + expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 50ecae7d024..7236f7bab2c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -740,25 +740,30 @@ final class Provider { } if let point = center(record.localFrame, in: snapshot.windowBounds) { try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported") + return actionMetadata( + path: "synthetic", + fallbackReason: "actionUnsupported", + verification: unverifiedAction(reason: "synthetic_input") + ) } throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame") } let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot) try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic") + return actionMetadata( + path: "synthetic", + verification: unverifiedAction(reason: "synthetic_input") + ) } private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? { @@ -2286,7 +2291,6 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? { private enum Input { static func click( - pid: pid_t, at point: CGPoint, button: MouseButton, count: Int, @@ -2298,10 +2302,32 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - for _ in 0.. 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + event.post(tap: .cghidEventTap) + usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds) } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift new file mode 100644 index 00000000000..d61e56af35c --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -0,0 +1,40 @@ +/// Event plan for synthetic mouse clicks (STA-3433). +/// +/// Clicks must be posted to the HID event tap, not `CGEventPostToPid`: +/// pid-targeted mouse events reach the app with no window association, so +/// AppKit never routes the press to a view (hover fires, activation never +/// happens). The window server also drops a mouseUp posted back-to-back +/// with its mouseDown, so consecutive events need a pause between them. +public enum SyntheticMouseClickDelivery { + public enum Step: Equatable { + case move + case buttonDown(pressIndex: Int) + case buttonUp(pressIndex: Int) + } + + /// Pause after posting each event; unpaced posts race the window + /// server's routing and the mouseUp is silently dropped. + public static let interEventPauseMicroseconds: UInt32 = 50_000 + + /// One move, then a paired down/up per press. `pressIndex` becomes the + /// event's click state so repeated presses register as double/triple + /// clicks instead of independent single clicks. + public static func steps(clickCount: Int) -> [Step] { + var steps: [Step] = [.move] + for press in 1...max(clickCount, 1) { + steps.append(.buttonDown(pressIndex: press)) + steps.append(.buttonUp(pressIndex: press)) + } + return steps + } + + /// Click state field value for a step; 0 leaves the field unset. + public static func clickState(for step: Step) -> Int64 { + switch step { + case .move: + return 0 + case let .buttonDown(pressIndex), let .buttonUp(pressIndex): + return Int64(pressIndex) + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift new file mode 100644 index 00000000000..d80a5e9e8ee --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class SyntheticMouseClickDeliveryTests: XCTestCase { + func testSingleClickPlanPairsDownAndUpAfterMove() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 1), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + + func testMultiClickPlanNumbersEachPressForClickState() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 2), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + ] + ) + } + + func testNonPositiveClickCountStillDeliversOnePress() { + for count in [0, -3] { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: count), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + } + + func testClickStateMatchesPressIndexAndSkipsMove() { + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonUp(pressIndex: 2)), 2) + } + + func testInterEventPauseIsNonZero() { + // Unpaced posts race the window server and the mouseUp is dropped, + // turning the click into a hover-only no-op (STA-3433). + XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) + } +} diff --git a/tests/e2e/computer-mac.e2e.ts b/tests/e2e/computer-mac.e2e.ts index 6e6c0a60873..6aa162cee40 100644 --- a/tests/e2e/computer-mac.e2e.ts +++ b/tests/e2e/computer-mac.e2e.ts @@ -71,6 +71,84 @@ describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => expect(after.result.snapshot.treeText).toContain(marker) }) + test('coordinate double-click activates a control, not just hover (STA-3433)', async () => { + // Ten identical short lines: any first-lines click hits a word, and the + // whole document stays inside the treeText value preview after editing. + const filler = Array(10).fill('wordword').join('\n') + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+A', + '--no-screenshot' + ]) + await runOrcaCli([ + 'computer', + 'paste-text', + '--app', + 'TextEdit', + '--text', + filler, + '--no-screenshot' + ]) + + // Word-select via coordinates: 40px in, 70px down lands inside a + // "wordword" on one of the first lines whether or not the ruler is shown. + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--x', + '40', + '--y', + '70', + '--click-count', + '2', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + expect(clicked.result.action?.path).toBe('synthetic') + expect(clicked.result.action?.verification).toMatchObject({ + state: 'unverified', + reason: 'synthetic_input' + }) + + const marker = `zz${Date.now()}zz` + await runOrcaCli([ + 'computer', + 'type-text', + '--app', + 'TextEdit', + '--text', + marker, + '--no-screenshot' + ]) + + const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( + ( + await runOrcaCli([ + 'computer', + 'get-app-state', + '--app', + 'TextEdit', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + // The double-click selected a mid-document word, so the typed marker must + // be followed by more filler. A dropped press leaves the caret at the end + // of the document and the marker only ever appends (the STA-3433 no-op). + expect(after.result.snapshot.treeText).toMatch(new RegExp(`${marker}\\s+wordword`)) + }) + test('paste-text and hotkey verify TextEdit text replacement', async () => { const first = parseJsonOutput<{ result: ComputerActionResult }>( ( From 215604f45819d524a0f25c1a02b6a100cce1a46f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:25:01 -0700 Subject: [PATCH 32/88] fix(tasks): hold dialog-confirmed issue state over stale Tasks list refetches (STA-3343) (#12830) * fix(tasks): hold dialog-confirmed issue state over stale list refetches (STA-3343) Closing an issue from GitHubItemDialog patched workItemsCache directly with no mutation-registry record, so a search-lagged Tasks refetch (GitHub search index eventual consistency + gh's ~120s URL cache) silently reverted the row to Open. Record the confirmed state as registry authority (same mechanism the list-row mutations use) so list fetch paths re-assert it until search catches up; quiet adopt still releases it on match, so external reverts win once the index is fresh. Covers issue close/reopen, PR close/reopen, and PR merge in the dialog. * fix(tasks): preserve newer state authority on rollback --- .../src/components/GitHubItemDialog.tsx | 37 +++- ...github-item-dialog-source-boundary.test.ts | 19 ++ ...page-github-dialog-state-authority.test.ts | 187 ++++++++++++++++++ ...task-page-github-dialog-state-authority.ts | 59 ++++++ 4 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-dialog-state-authority.test.ts create mode 100644 src/renderer/src/components/task-page-github-dialog-state-authority.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 919b908f375..0e50c3bbc50 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -210,6 +210,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' import { getCheckConclusion, @@ -2834,6 +2835,14 @@ function PRActionsPanel({ } const previousState = localState setStatePending(true) + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + const authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: nextState, + sourceContext + }) applyStatePatch(nextState) try { await runPullRequestStateUpdate({ @@ -2853,7 +2862,9 @@ function PRActionsPanel({ ) onMutated() } catch (err) { - applyStatePatch(previousState) + if (authority.revert()) { + applyStatePatch(previousState) + } toast.error( err instanceof Error ? err.message @@ -2913,6 +2924,13 @@ function PRActionsPanel({ toast.error(result.error) return } + // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. + assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: 'merged', + sourceContext + }) applyStatePatch('merged') if (mergeTarget.kind === 'environment') { notifyWorkItemDetailsMutation( @@ -4175,6 +4193,9 @@ function GHEditSection({ return } const prevState = localState + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + let authority: { revert: () => boolean } | null = null run('state', { mutate: () => runIssueUpdate({ @@ -4189,14 +4210,22 @@ function GHEditSection({ : { state: newState } }), onOptimistic: () => { + authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: newState, + sourceContext + }) onStateChange(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { - onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: prevState }) + if (authority?.revert()) { + onStateChange(prevState) + patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded({ state: prevState }) + } }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 4aa11354059..a773910d332 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -242,4 +242,23 @@ describe('GitHubItemDialog source host boundaries', () => { expect(checksTab).toContain('item={displayWorkItem ?? workItem}') }) + + it('records state authority for dialog state mutations so stale list refetches cannot revert them (STA-3343)', () => { + const source = componentSource('GitHubItemDialog.tsx') + + // Issue close/reopen: assert on optimistic apply, revert on failure. + const editSection = sourceBetween(source, 'function GHEditSection', 'const closeAsDuplicate') + expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') + expect(editSection).toContain('if (authority?.revert())') + + // PR close/reopen + merge: same protection for the shared Tasks list rows. + const actionsSection = sourceBetween( + source, + 'function PRActionsPanel', + 'function CommentReplyForm' + ) + expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) + expect(actionsSection).toContain('if (authority.revert())') + expect(actionsSection).toContain("state: 'merged'") + }) }) diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts new file mode 100644 index 00000000000..2f4d1646d21 --- /dev/null +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { GitHubWorkItem } from '../../../shared/types' +import { getTaskSourceCacheScope } from '../../../shared/task-source-context' +import { assertTaskPageGitHubDialogStateAuthority } from './task-page-github-dialog-state-authority' +import { + adoptQuietSearchFieldsForItem, + applyPendingTaskPageGitHubMutationsToItems, + materializeTaskPageItemList, + reapplyPendingTaskPageGitHubMutationsToCache +} from './task-page-github-work-item-mutations' +import { + deleteLastConfirmedClientValue, + getLastConfirmedClientValue, + getOrCreateQuietRevalidateState, + resetTaskPageGitHubMutationRegistryForTests, + setLastConfirmedClientValue, + setTaskPageGitHubMutationQueryKey, + taskPageGitHubFamilyDirtyKey, + taskPageGitHubItemKey +} from './task-page-github-work-item-mutation-registry' + +function item(overrides: Partial = {}): GitHubWorkItem { + return { + id: 'issue:1', + type: 'issue', + number: 1, + title: 't', + state: 'open', + url: 'https://github.com/o/r/issues/1', + labels: [], + updatedAt: '2026-01-01T00:00:00Z', + author: 'author', + repoId: 'repo-1', + ...overrides + } +} + +const sourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + hostId: 'local' as const, + projectId: 'proj', + projectHostSetupId: 'setup', + repoId: 'repo-1' +} + +afterEach(() => resetTaskPageGitHubMutationRegistryForTests()) + +describe('dialog state authority (STA-3343)', () => { + it('without authority a stale search refetch reverts a cache-only patch (external edits adopt)', () => { + // Why: documents the pre-fix dialog behavior — patchWorkItem alone gives the + // row no registry protection, so the lagging search response wins. + const materialized = materializeTaskPageItemList({ + networkItems: [item({ state: 'open' })], + previousItems: [item({ state: 'closed' })], + queryKey: 'q' + }) + expect(materialized[0]?.state).toBe('open') + }) + + it('holds a dialog-confirmed close over a stale search refetch', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const materialized = materializeTaskPageItemList({ + networkItems: [item({ state: 'open' })], + previousItems: [item({ state: 'closed' })], + queryKey: 'q' + }) + expect(materialized[0]?.state).toBe('closed') + }) + + it('holds the close through the cache re-apply path too', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const store = new Map([['issue:1', item({ state: 'open' })]]) + reapplyPendingTaskPageGitHubMutationsToCache({ + items: [item({ state: 'open' })], + patchWorkItem: (id, patch) => { + store.set(id, { ...(store.get(id) ?? item()), ...patch }) + } + }) + expect(store.get('issue:1')?.state).toBe('closed') + }) + + it('records authority under the mutation source scope so other scopes are untouched', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed', + sourceContext + }) + const scope = getTaskSourceCacheScope(sourceContext) + expect(getLastConfirmedClientValue(scope, 'repo-1', 'issue:1', 'state')).toBe('closed') + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + // Why: overlay resolves the remembered scope, so the list row still holds. + const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })]) + expect(overlaid[0]?.state).toBe('closed') + }) + + it('marks the state family dirty so quiet revalidation trails the stale row', () => { + setTaskPageGitHubMutationQueryKey('q') + const before = getOrCreateQuietRevalidateState('q').dirtyGeneration + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const quiet = getOrCreateQuietRevalidateState('q') + expect(quiet.dirtyGeneration).toBe(before + 1) + expect( + quiet.familyDirtyAt.get( + taskPageGitHubFamilyDirtyKey(taskPageGitHubItemKey('repo-1', 'issue:1'), 'state') + ) + ).toBe(quiet.dirtyGeneration) + }) + + it('releases authority once search reports the confirmed state, so later external edits win', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const patched: Partial[] = [] + adoptQuietSearchFieldsForItem({ + item: item({ state: 'closed' }), + serverItem: item({ state: 'closed' }), + sourceScope: null, + queryKey: 'q', + fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration, + patchWorkItem: (_id, patch) => patched.push(patch) + }) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })]) + expect(overlaid[0]?.state).toBe('open') + }) + + it('revert drops fresh authority and restores a pre-existing value', () => { + setTaskPageGitHubMutationQueryKey('q') + const fresh = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + expect(fresh.revert()).toBe(true) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + + setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'open') + const layered = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + expect(layered.revert()).toBe(true) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('open') + }) + + it('does not roll back authority released by search or superseded by a newer state', () => { + setTaskPageGitHubMutationQueryKey('q') + const released = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + deleteLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state') + expect(released.revert()).toBe(false) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + + const superseded = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'merged') + expect(superseded.revert()).toBe(false) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('merged') + }) +}) diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.ts new file mode 100644 index 00000000000..a372762e141 --- /dev/null +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.ts @@ -0,0 +1,59 @@ +import type { GitHubWorkItem } from '../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' +import { + deleteLastConfirmedClientValue, + getLastConfirmedClientValue, + getTaskPageGitHubMutationQueryKey, + markTaskPageGitHubFamiliesDirty, + notifyTaskPageGitHubMutationRegistry, + setLastConfirmedClientValue, + taskPageGitHubItemKey +} from './task-page-github-work-item-mutation-registry' + +function markStateFamilyDirty(repoId: string, itemId: string): void { + const queryKey = getTaskPageGitHubMutationQueryKey() + if (queryKey !== null) { + markTaskPageGitHubFamiliesDirty(queryKey, taskPageGitHubItemKey(repoId, itemId), ['state']) + } +} + +/** + * Dialog state edits patch `workItemsCache` directly with no pending op, so a + * search-lagged list refetch (GitHub search index + `gh` URL cache) silently + * reverted them in the Tasks list (STA-3343). Record the confirmed state in the + * mutation registry so the list fetch paths re-assert it until search catches + * up; quiet adopt releases it on match, so external reverts still win. + */ +export function assertTaskPageGitHubDialogStateAuthority(args: { + repoId: string + itemId: string + state: GitHubWorkItem['state'] + sourceContext?: TaskSourceContext | null +}): { revert: () => boolean } { + const sourceScope = + args.sourceContext?.provider === 'github' ? getTaskSourceCacheScope(args.sourceContext) : null + const previous = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', args.state) + markStateFamilyDirty(args.repoId, args.itemId) + notifyTaskPageGitHubMutationRegistry() + return { + revert: () => { + const current = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + // A matching search adopt or newer mutation owns the state now. + if (current !== args.state) { + return false + } + if (previous === undefined) { + deleteLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + } else { + setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', previous) + } + markStateFamilyDirty(args.repoId, args.itemId) + notifyTaskPageGitHubMutationRegistry() + return true + } + } +} From 790ade578a488cdf9d2c4470c7aa1a4cd3321496 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:27:38 -0700 Subject: [PATCH 33/88] fix(browser): recover browser tab when guest WebContents is destroyed (STA-3448) (#12831) * fix(browser): recover browser tab when guest WebContents is destroyed (STA-3448) A whose guest WebContents died without render-process-gone (detach/reattach race, guest-side close) stayed attached and painted black forever; reload and focus on the dead guest threw uncaught. - listen for the webview 'destroyed' event at both layers, mirroring render-process-gone: registry marks recovery pending (survives pane unmount), BrowserPane triggers guest recovery immediately - route reload-on-dead-guest into guest recovery instead of throwing (toolbar, Cmd+R renderer+IPC paths, context menu) - guard webview.focus() against Electron's null-internals throw * fix(browser): close guest reload destruction race --- .../components/browser-pane/BrowserPane.tsx | 55 +++++++++++----- .../browser-reload-action.test.ts | 62 ++++++++++++++++++- .../browser-pane/browser-reload-action.ts | 34 ++++++++++ .../browser-pane/webview-registry.test.ts | 39 ++++++++++++ .../browser-pane/webview-registry.ts | 18 +++++- 5 files changed, 191 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a452757ecc4..d1aca3c49b6 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -197,6 +197,7 @@ import { import { subscribeBrowserSystemResume } from './browser-system-resume' import { type BrowserReloadTrigger, + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -2941,6 +2942,20 @@ function BrowserPagePane({ () => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }), [browserTab.loading, browserTab.loadError] ) + const reloadWebviewOrRecoverGuest = useCallback( + (ignoreCache: boolean) => { + const webview = webviewRef.current + if (!webview) { + return + } + if (reloadBrowserPageWebview(webview, { ignoreCache }) === 'guest-missing') { + // Why: reload cannot revive a destroyed guest (STA-3448) — recreate it instead. + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryGuestRecoveryRef.current() + } + }, + [browserTab.id] + ) const runReloadTrigger = useCallback( (trigger: BrowserReloadTrigger) => { const webview = webviewRef.current @@ -2959,14 +2974,14 @@ function BrowserPagePane({ retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) break case 'hard-reload': - webview.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) break case 'reload': - webview.reload() + reloadWebviewOrRecoverGuest(false) break } }, - [browserTab, reloadState] + [browserTab, reloadState, reloadWebviewOrRecoverGuest] ) // Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure. @@ -3454,7 +3469,12 @@ function BrowserPagePane({ return false } addressBarInputRef.current?.blur() - webview.focus() + try { + webview.focus() + } catch { + // Why: WebViewElement.focus() reads null internals once the guest is destroyed (STA-3448). + return false + } return document.activeElement === webview }, []) @@ -3660,15 +3680,11 @@ function BrowserPagePane({ } e.preventDefault() e.stopPropagation() - if (isHardReload) { - webviewRef.current?.reloadIgnoringCache() - } else { - webviewRef.current?.reload() - } + reloadWebviewOrRecoverGuest(isHardReload) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [isActive, keybindings]) + }, [isActive, keybindings, reloadWebviewOrRecoverGuest]) // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. @@ -3677,9 +3693,9 @@ function BrowserPagePane({ return } return window.api.ui.onReloadBrowserPage(() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { if (!isActive) { @@ -3716,9 +3732,9 @@ function BrowserPagePane({ return } return window.api.ui.onHardReloadBrowserPage(() => { - webviewRef.current?.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { onUpdatePageStateRef.current = onUpdatePageState @@ -4220,9 +4236,17 @@ function BrowserPagePane({ validateVisibleGuestRegistrationRef.current = guestRecovery.validateAfterResume retryGuestRecoveryRef.current = guestRecovery.retryRecovery + const handleGuestDestroyed = (): void => { + // Why: a guest can be destroyed without render-process-gone (detach/reattach race, + // guest-side close). Skip intentional teardown, where the element already left the DOM. + if (webview.isConnected) { + guestRecovery.recoverRenderer() + } + } webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.addEventListener('destroyed', handleGuestDestroyed) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) webview.addEventListener('did-start-navigation', handleDidStartNavigation) @@ -4259,6 +4283,7 @@ function BrowserPagePane({ webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.removeEventListener('destroyed', handleGuestDestroyed) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) webview.removeEventListener('did-start-navigation', handleDidStartNavigation) @@ -5161,7 +5186,7 @@ function BrowserPagePane({ role="menuitem" className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14" onClick={() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) setContextMenu(null) }} > diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts index ec14e666826..2f9748767a1 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' import { + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -51,3 +52,62 @@ describe('resolveBrowserReloadButtonLabelKind', () => { expect(resolveBrowserReloadButtonLabelKind(guestFailed)).toBe('retry') }) }) + +describe('reloadBrowserPageWebview', () => { + function createWebview(overrides: Partial = {}): Electron.WebviewTag { + return { + getWebContentsId: vi.fn(() => 42), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + ...overrides + } as unknown as Electron.WebviewTag + } + + it('reloads a live guest, honoring the cache flag', () => { + const webview = createWebview() + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('reloaded') + expect(webview.reload).toHaveBeenCalledTimes(1) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: true })).toBe('reloaded') + expect(webview.reloadIgnoringCache).toHaveBeenCalledTimes(1) + }) + + // Why: a destroyed guest makes reload() throw uncaught (STA-3448) — callers must recreate the guest instead. + it('reports a missing guest without touching reload', () => { + const webview = createWebview({ + getWebContentsId: vi.fn(() => { + throw new Error('WebContents is destroyed') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(webview.reload).not.toHaveBeenCalled() + }) + + it('reports a guest destroyed between the liveness probe and reload', () => { + const getWebContentsId = vi + .fn<() => number>() + .mockReturnValueOnce(42) + .mockImplementation(() => { + throw new Error('WebContents is destroyed') + }) + const webview = createWebview({ + getWebContentsId, + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(getWebContentsId).toHaveBeenCalledTimes(2) + }) + + // Why: pre-dom-ready reload throws too, but a live guest is already loading — recovery must not replace it. + it('reports not-ready when a live guest rejects the reload', () => { + const webview = createWebview({ + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('not-ready') + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.ts b/src/renderer/src/components/browser-pane/browser-reload-action.ts index 2bebeaa9cd9..b4ecb6d9b4f 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.ts @@ -34,6 +34,40 @@ export function resolveBrowserReloadIntent( return trigger === 'hard-reload' ? 'hard-reload' : 'reload' } +export type BrowserPageWebviewReloadResult = 'reloaded' | 'guest-missing' | 'not-ready' + +/** + * Why: reload on a guestless webview throws uncaught ("The WebView must be attached to the + * DOM…", STA-3448). getWebContentsId() only needs an attached guest, so it separates a dead + * guest (route to guest recovery) from a live pre-dom-ready one (a load is already in flight). + */ +export function reloadBrowserPageWebview( + webview: Electron.WebviewTag, + { ignoreCache }: { ignoreCache: boolean } +): BrowserPageWebviewReloadResult { + try { + webview.getWebContentsId() + } catch { + return 'guest-missing' + } + try { + if (ignoreCache) { + webview.reloadIgnoringCache() + } else { + webview.reload() + } + } catch { + try { + webview.getWebContentsId() + } catch { + // Why: the guest can be destroyed between the liveness probe and reload. + return 'guest-missing' + } + return 'not-ready' + } + return 'reloaded' +} + /** Accessible name for the toolbar button, which is Stop mid-load and Retry after a failure. */ export type BrowserReloadButtonLabelKind = 'stop' | 'retry' | 'reload' diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/webview-registry.test.ts index b4abfee6b7e..c1ec7181e85 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.test.ts @@ -165,6 +165,45 @@ describe('webview registry drag listeners', () => { expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) }) + it('flags renderer recovery when the guest is destroyed under a still-attached webview', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(true) + + webview.dispatchEvent(new Event('dom-ready')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('ignores guest destruction caused by intentional webview removal', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: false }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('stops listening for guest destruction after unregistration', async () => { + const { + isBrowserPageRendererRecoveryPending, + registerPersistentWebview, + unregisterPersistentWebview + } = await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + unregisterPersistentWebview('page-1') + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + it('preserves the viewport and zoom only while replacing a guest', async () => { const { destroyPersistentWebview, registerPersistentWebview, replacePersistentWebview } = await import('./webview-registry') diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index 3ce94e15dad..f64f0dfc0e3 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -27,6 +27,7 @@ const webviewLifecycleListeners = new Map< webview: Electron.WebviewTag onRendererGone: EventListener onRendererReady: EventListener + onGuestDestroyed: EventListener } >() @@ -163,6 +164,7 @@ export function registerPersistentWebview( previousListeners.onRendererGone ) previousListeners.webview.removeEventListener('dom-ready', previousListeners.onRendererReady) + previousListeners.webview.removeEventListener('destroyed', previousListeners.onGuestDestroyed) } const onRendererGone = (): void => { rendererRecoveryPendingPageIds.add(browserTabId) @@ -170,9 +172,22 @@ export function registerPersistentWebview( const onRendererReady = (): void => { rendererRecoveryPendingPageIds.delete(browserTabId) } + const onGuestDestroyed = (): void => { + // Why: 'destroyed' also fires after an intentional webview.remove(); only a + // still-attached element means the guest died under a live tab (STA-3448). + if (webview.isConnected) { + rendererRecoveryPendingPageIds.add(browserTabId) + } + } webview.addEventListener('render-process-gone', onRendererGone) webview.addEventListener('dom-ready', onRendererReady) - webviewLifecycleListeners.set(browserTabId, { webview, onRendererGone, onRendererReady }) + webview.addEventListener('destroyed', onGuestDestroyed) + webviewLifecycleListeners.set(browserTabId, { + webview, + onRendererGone, + onRendererReady, + onGuestDestroyed + }) webviewRegistry.set(browserTabId, webview) applyCurrentDragPassthroughToWebview(webview) ensureDragListeners() @@ -187,6 +202,7 @@ export function unregisterPersistentWebview(browserTabId: string): void { lifecycleListeners.onRendererGone ) lifecycleListeners.webview.removeEventListener('dom-ready', lifecycleListeners.onRendererReady) + lifecycleListeners.webview.removeEventListener('destroyed', lifecycleListeners.onGuestDestroyed) webviewLifecycleListeners.delete(browserTabId) } rendererRecoveryPendingPageIds.delete(browserTabId) From ab1bf1aff535b048af3d399d7da904eedcdeb1b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:40:27 -0700 Subject: [PATCH 34/88] fix(settings): recover HTTP proxy settings from safeStorage failures (STA-3442) (#12838) httpProxyUrl is the only network setting stored via safeStorage. Two failure modes silently killed the configured proxy on macOS: - A keychain reset/denial makes decryptString throw at load; the raw ciphertext then masqueraded as the configured proxy URL, so applyElectronProxySettings silently fell back to DIRECT and the garbage re-persisted forever (no self-heal). - safeStorage.isEncryptionAvailable() throwing (keychain/API errors, pre-ready use) was uncaught in encrypt/decrypt, failing the entire state save - the data file never gained the proxy keys at all. Load now validates the decrypted value and clears undecryptable ciphertext (plaintext URLs still pass, preserving the pre-encryption upgrade path), the availability check is exception-safe, and startup logs when persisted proxy settings are invalid instead of silently using direct networking. --- src/main/index.ts | 6 +- .../persistence-proxy-secret-recovery.test.ts | 186 ++++++++++++++++++ src/main/persistence.ts | 30 ++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/main/persistence-proxy-secret-recovery.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94..ebabeb2b18e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2098,7 +2098,11 @@ void app.whenReady().then(async () => { } try { // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - await applyElectronProxySettings(store.getSettings()) + const proxyApplyResult = await applyElectronProxySettings(store.getSettings()) + if (proxyApplyResult.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } } catch { console.warn('[proxy] Failed to apply network proxy settings') } diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts new file mode 100644 index 00000000000..bc6e03483bb --- /dev/null +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -0,0 +1,186 @@ +// STA-3442: httpProxyUrl is the only network setting stored via safeStorage. +// On macOS a keychain reset/denial makes decryptString throw at load, and the +// raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings +// silently fell back to DIRECT and the garbage re-persisted forever. These tests +// pin the recovery contract: undecryptable values are cleared (never applied, +// never re-saved as garbage), plaintext values survive as the upgrade path, and +// a throwing safeStorage.isEncryptionAvailable() cannot kill the whole save. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +const cipherState = { + encryptionAvailable: true, + availabilityThrows: false, + decryptAlwaysThrows: false +} + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + session: { defaultSession: undefined }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availabilityThrows) { + throw new Error('safeStorage cannot be used before the app is ready') + } + return cipherState.encryptionAvailable + }, + encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptAlwaysThrows) { + throw new Error('keychain access denied') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +const PROXY_URL = 'http://127.0.0.1:8080' +const BYPASS_RULES = '' + +describe('httpProxyUrl secret recovery (STA-3442)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + cipherState.encryptionAvailable = true + cipherState.availabilityThrows = false + cipherState.decryptAlwaysThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function seedConfiguredProxy() { + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + return store + } + + it('persists the configured proxy across a restart and applies it as fixed_servers', async () => { + await seedConfiguredProxy() + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + // On disk the URL is ciphertext (base64 of the mock's enc: payload), never plaintext. + expect(persisted.settings.httpProxyUrl).not.toBe(PROXY_URL) + expect(Buffer.from(persisted.settings.httpProxyUrl, 'base64').toString('utf-8')).toMatch( + /^enc:/ + ) + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + const { applyElectronProxySettings, resetProxyApplicationForTests } = + await import('./network/proxy-settings') + resetProxyApplicationForTests() + const setProxy = vi.fn(async () => {}) + const result = await applyElectronProxySettings(reloaded.getSettings(), { + proxySession: { resolveProxy: async () => 'DIRECT', setProxy }, + env: {} + }) + expect(result).toEqual({ + source: 'settings', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + expect(setProxy).toHaveBeenCalledWith({ + mode: 'fixed_servers', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + }) + + it('clears an undecryptable httpProxyUrl instead of surfacing ciphertext as settings', async () => { + await seedConfiguredProxy() + + // Keychain reset/denial: every decrypt now fails. + cipherState.decryptAlwaysThrows = true + const reloaded = await createStore() + + expect(reloaded.getSettings().httpProxyUrl).toBe('') + // Bypass rules are not encrypted and must survive. + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + // The cleanup must reach disk so garbage never round-trips back in. + vi.advanceTimersByTime(2000) + await reloaded.waitForPendingWrite() + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string } + } + expect(persisted.settings.httpProxyUrl).toBe('') + }) + + it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => { + mkdirSync(dirname(dataFile()), { recursive: true }) + writeFileSync( + dataFile(), + JSON.stringify({ settings: { httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES } }), + 'utf-8' + ) + + const store = await createStore() + expect(store.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + }) + + it('still saves and reloads the proxy when safeStorage.isEncryptionAvailable throws', async () => { + cipherState.availabilityThrows = true + + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + + // Encryption degraded to plaintext passthrough; the save must not be lost. + expect(existsSync(dataFile())).toBe(true) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + expect(persisted.settings.httpProxyUrl).toBe(PROXY_URL) + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index be3f10fd78f..565b6fd0032 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -38,6 +38,7 @@ import { } from '../shared/automation-schedules' import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' +import { normalizeProxyUrl } from '../shared/network-proxy' import type { PersistedState, Project, @@ -279,8 +280,19 @@ import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' +// Why (STA-3442): isEncryptionAvailable() itself can throw (keychain/API errors, pre-ready +// use); an uncaught throw here failed the entire save/load, silently losing every setting. +function safeStorageEncryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch (err) { + console.warn('[persistence] safeStorage availability check failed:', err) + return false + } +} + function encrypt(plaintext: string): string { - if (!plaintext || !safeStorage.isEncryptionAvailable()) { + if (!plaintext || !safeStorageEncryptionAvailable()) { return plaintext } try { @@ -292,7 +304,7 @@ function encrypt(plaintext: string): string { } function decrypt(ciphertext: string): string { - if (!ciphertext || !safeStorage.isEncryptionAvailable()) { + if (!ciphertext || !safeStorageEncryptionAvailable()) { return ciphertext } try { @@ -3067,7 +3079,19 @@ export class Store { parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) } if (parsed.settings?.httpProxyUrl) { - parsed.settings.httpProxyUrl = decrypt(parsed.settings.httpProxyUrl) + const decryptedProxyUrl = decrypt(parsed.settings.httpProxyUrl) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if (normalizeProxyUrl(decryptedProxyUrl).ok) { + parsed.settings.httpProxyUrl = decryptedProxyUrl + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.loadNeedsSave = true + } } if (parsed.ui?.browserKagiSessionLink) { parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) From 12472b8a63d640d196396e5d84e7da8c4ccf796a Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:43:22 +0900 Subject: [PATCH 35/88] fix(skills): match official skill files despite local sidecars (#12812) * fix(skills): match official skill files despite local sidecars Scope known-snapshot matching to manifest-listed files so agent-written sidecars (e.g. agents/openai.yaml) no longer mark a package unrecognized and block updates when official bytes still match. Preserves fail-closed detection when a listed file's content drifts. Fixes #12694 * fix(skills): scope lock trust and convergence to official files too Sidecar tolerance stopped at the snapshot match, leaving three disk-vs-official comparisons still judging the whole folder. The lock-comparable hash covered every observed file, so a clean update beside agents/openai.yaml reported as failed and read 'may be modified'. It is now carried both whole and scoped to the current bundle's paths, and either may satisfy the lock: the sidecar case only ever matches scoped, while an upstream revision that ADDS a file only ever matches whole, so publishing one alone would trade this bug for #11220. Convergence re-derived the disk revision from that same whole-folder digest, which no revision matches once a sidecar lands, retiring the stuck-lock gate and arming an update the command provably cannot perform; it now honours the revision observation already resolved. Subset matching also let an older revision launder drift on a file the current bundle lists, since that revision does not list it and so read it as a neighbour. Identity now keys tolerance on what the current bundle owns. --------- Co-authored-by: Jinwoo-H --- .../skills/skill-freshness-inventory.test.ts | 151 +++++++++++++++- src/main/skills/skill-freshness-inventory.ts | 5 +- .../skill-freshness-placement-observation.ts | 16 +- .../skills/skill-package-identity.test.ts | 170 +++++++++++++++--- src/main/skills/skill-package-identity.ts | 69 ++++++- .../skills/skill-update-convergence.test.ts | 112 ++++++------ src/main/skills/skill-update-convergence.ts | 39 ++-- src/main/skills/skill-update-outcome.ts | 7 +- src/main/skills/skill-update-registration.ts | 27 +++ src/shared/skill-freshness.ts | 7 + 10 files changed, 477 insertions(+), 126 deletions(-) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 2b20055e646..f2cb31e1357 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -274,6 +274,139 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) + it('scopes the lock hash to the current bundle, not every path a revision ever shipped', async () => { + // A file revision 1 shipped and the current revision dropped is a stale leftover, not + // part of what the updater installed. Scoping to the union of all revisions would drag + // it back into the hash on the accident of its name, and the copy the CLI just wrote + // would read "may be modified". + const test = await fixture() + const legacy = describeObservedSkillFile( + 'references/legacy.md', + Buffer.from('dropped after rev 1\n'), + false + ) + const registryPath = join(test.resourceRoot, 'skills', 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'][0].files.push(legacy) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + // The lock records the source tree — SKILL.md alone, no leftover. + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile(join(canonical, 'references', 'legacy.md'), 'dropped after rev 1\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('trusts the updater lock for upstream bytes beside an agent CLI sidecar (#12694)', async () => { + // The reported folder shape after a successful update: `skills update` wrote + // source-repo HEAD no bundle knows yet, and Codex's own agents/openai.yaml sits + // beside it. The lock records the source tree — SKILL.md alone — so a folder hash + // taken over the sidecar too can never match it, and the copy the command just + // wrote would be reported as a failed update. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still trusts the lock when the upstream revision added a file (#11220 guard)', async () => { + // The other half of the sidecar fix: scoping the lock hash to official paths alone + // would drop a file upstream genuinely shipped, and that file IS in the lock's tree. + // A clean install would then read "may be modified" and its update run report failure. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile( + join(canonical, 'references', 'new.md'), + 'Shipped upstream, no bundle knows it\n' + ) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(canonical)) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still withholds an unwinnable update when a sidecar sits beside the stale copy', async () => { + // Recognising the copy is only half the job: the lock already names the revision the + // source has, so `skills update` would compare lock to source, see no work and write + // nothing. Offering it promises a button that can never finish. A sidecar must not + // make the placement unidentifiable and retire that guard. + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + // The fixture's synthetic tree sha for revision 2 — the revision on disk is 1. + await writeSkillLockHash(test.homeDir, (2).toString(16).padStart(40, '0')) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1 + }) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('still flags canonical bytes that do not match what the lock says was installed', async () => { const test = await fixture() const editedMarkdown = `${test.currentMarkdown}\nLocal edit the updater never wrote.\n` @@ -522,10 +655,14 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) - it('keeps a plugin-cache copy with known official files unrecognized', async () => { + it('reads a plugin-cache copy with untouched official files as current', async () => { + // The deliberate posture change behind #12694: an unlisted neighbour is not evidence + // of an edit, so the bytes Orca owns decide alone — here and in every scope, not just + // the canonical copy the updater writes. The drifted-SKILL.md case above still fails + // closed, which is what keeps "unrecognized" meaningful. const test = await fixture() await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const modifiedRoot = join( + const withSidecarRoot = join( test.homeDir, '.codex', 'plugins', @@ -534,9 +671,9 @@ describe('read-only skill freshness inventory', () => { 'modified', 'orca-cli' ) - await mkdir(modifiedRoot, { recursive: true }) - await writeFile(join(modifiedRoot, 'SKILL.md'), test.currentMarkdown) - await writeFile(join(modifiedRoot, 'README.md'), 'Modified official package\n') + await mkdir(withSidecarRoot, { recursive: true }) + await writeFile(join(withSidecarRoot, 'SKILL.md'), test.currentMarkdown) + await writeFile(join(withSidecarRoot, 'README.md'), 'Neighbouring file Orca never shipped\n') const inventory = await inventorySkillFreshness({ currentAppVersion: '2.0.0', @@ -548,8 +685,8 @@ describe('read-only skill freshness inventory', () => { expect(inventory.installations).toEqual( expect.arrayContaining([ expect.objectContaining({ - unresolvedPath: modifiedRoot, - status: 'unrecognized' + unresolvedPath: withSidecarRoot, + status: 'current' }) ]) ) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index a3747451898..f502707321e 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -19,7 +19,7 @@ import { } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' import { convergableSkillNames } from './skill-update-convergence' -import { readGloballyUpdatableSkillLocks } from './skill-update-registration' +import { matchesUpdaterLock, readGloballyUpdatableSkillLocks } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -35,8 +35,7 @@ function trustLockInstalledRevision( ): SkillFreshnessInstallation { return installation.status === 'unrecognized' && SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) && - installation.observedGitTreeSha != null && - installation.observedGitTreeSha === globalSkillLocks.get(installation.name) + matchesUpdaterLock(installation, globalSkillLocks.get(installation.name)) ? { ...installation, status: 'newer-known' } : installation } diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 87911de4823..02c9dcf80f5 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -8,7 +8,11 @@ import type { } from '../../shared/skill-freshness' import type { SkillScanRoot } from './skill-discovery-sources' import type { SkillBundleArtifacts } from './skill-bundle-artifacts' -import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + matchingKnownSnapshot, + observeSkillPackage, + officialPathsGitTreeSha +} from './skill-package-identity' import { classifyHomeSkillTopology, classifyUnsupportedSkillTopology, @@ -95,10 +99,9 @@ export async function observeSkillFreshnessInstallation(args: { try { const observed = await observeSkillPackage(args.topology.resolvedPath) - const matchedSnapshot = matchingKnownSnapshot( - observed, - knownSnapshots(args.artifacts, args.current) - ) + const snapshots = knownSnapshots(args.artifacts, args.current) + const officialPaths = new Set(args.current.files.map((file) => file.path)) + const matchedSnapshot = matchingKnownSnapshot(observed, snapshots, officialPaths) // Why: a later release can reintroduce identical bytes. Exact current // identity is still current, and cannot honestly be attributed to the later tag. const snapshot = @@ -117,7 +120,8 @@ export async function observeSkillFreshnessInstallation(args: { null) : null, observedPackageDigest: observed.observedDigest, - observedGitTreeSha: observed.observedGitTreeSha + observedGitTreeSha: observed.observedGitTreeSha, + observedOfficialGitTreeSha: officialPathsGitTreeSha(observed, officialPaths) } } catch (error) { return { diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts index ca9beb9fd3d..7d362be9e7d 100644 --- a/src/main/skills/skill-package-identity.test.ts +++ b/src/main/skills/skill-package-identity.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -6,6 +6,7 @@ import { describeObservedSkillFile, matchingKnownSnapshot, observeSkillPackage, + officialPathsGitTreeSha, skillPackageDigest } from './skill-package-identity' @@ -17,6 +18,13 @@ async function temporarySkill(): Promise { return root } +/** A byte-identical folder elsewhere, so a hash can be derived without the original. */ +async function copyOf(source: string): Promise { + const target = await temporarySkill() + await cp(source, target, { recursive: true }) + return target +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) }) @@ -32,14 +40,18 @@ describe('skill package identity', () => { // hashes, not raw file buffers, should survive each file's identity pass. expect(observed.files[0]).not.toHaveProperty('bytes') expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: skillPackageDigest([expected]), - gitTreeSha: 'tree', - files: [expected] - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -102,14 +114,18 @@ describe('skill package identity', () => { // has to come out clean too, not just the digest. expect(observed.observedGitTreeSha).toBe(official.observedGitTreeSha) expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: official.observedDigest, - gitTreeSha: official.observedGitTreeSha, - files: official.files - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: official.observedDigest, + gitTreeSha: official.observedGitTreeSha, + files: official.files + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -141,11 +157,13 @@ describe('skill package identity', () => { const edited = await temporarySkill() await writeFile(join(edited, 'SKILL.md'), 'skill\nlocal tweak\n') await writeFile(join(edited, '.DS_Store'), Buffer.from([0, 1])) - // An unexpected file that is NOT OS metadata must keep failing closed; tolerating - // extras in general would let an injected payload ride along beside a clean SKILL.md. + // Extra sidecar next to an untouched SKILL.md must still match — agent CLIs + // write agents/openai.yaml (and similar) without editing official bytes. + // Content drift on a listed file remains fail-closed. const withPayload = await temporarySkill() await writeFile(join(withPayload, 'SKILL.md'), 'skill\n') - await writeFile(join(withPayload, 'payload.sh'), '#!/bin/sh\n') + await mkdir(join(withPayload, 'agents'), { recursive: true }) + await writeFile(join(withPayload, 'agents', 'openai.yaml'), 'display_name: test\n') const snapshot = [ { @@ -155,8 +173,116 @@ describe('skill package identity', () => { files: official.files } ] - expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot)).toBeNull() - expect(matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot)).toBeNull() + const official1 = new Set(['SKILL.md']) + expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot, official1)).toBeNull() + expect( + matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot, official1) + ?.releaseRevision + ).toBe(1) + }) + + it('does not let an older revision launder drift on a file the current one lists', async () => { + // Subset matching treats a path the tested revision does not list as a neighbour. + // Left unguarded, revision 1 (SKILL.md alone) would match a revision-2 folder whose + // references/x.md had been rewritten — reporting a tampered package as merely + // outdated, with an update offered and no "may be modified" anywhere. + const pristine = await temporarySkill() + await writeFile(join(pristine, 'SKILL.md'), 'skill\n') + const revisionOne = await observeSkillPackage(pristine) + + const tampered = await temporarySkill() + await writeFile(join(tampered, 'SKILL.md'), 'skill\n') + await mkdir(join(tampered, 'references'), { recursive: true }) + await writeFile(join(tampered, 'references', 'x.md'), 'attacker content\n') + + const snapshots = [ + { + releaseRevision: 1, + packageDigest: revisionOne.observedDigest, + gitTreeSha: revisionOne.observedGitTreeSha, + files: revisionOne.files + } + ] + const observed = await observeSkillPackage(tampered) + + expect( + matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md', 'references/x.md'])) + ).toBeNull() + // The same folder is a plain sidecar case once the current bundle stops claiming + // that path, so the guard must key on what is official — not on file count. + expect(matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md']))?.releaseRevision).toBe( + 1 + ) + }) + + it('hashes official paths for the lock without hiding an upstream file it added', async () => { + const clean = await temporarySkill() + await writeFile(join(clean, 'SKILL.md'), 'skill\n') + const source = await observeSkillPackage(clean) + + const withSidecar = await temporarySkill() + await writeFile(join(withSidecar, 'SKILL.md'), 'skill\n') + await mkdir(join(withSidecar, 'agents'), { recursive: true }) + await writeFile(join(withSidecar, 'agents', 'openai.yaml'), 'display_name: test\n') + const sidecarObserved = await observeSkillPackage(withSidecar) + const officialPaths = new Set(['SKILL.md']) + + // A sidecar folder reaches the lock's source-tree hash only once scoped. + expect(sidecarObserved.observedGitTreeSha).not.toBe(source.observedGitTreeSha) + expect(officialPathsGitTreeSha(sidecarObserved, officialPaths)).toBe(source.observedGitTreeSha) + + // An upstream revision that ADDS a file puts it in the lock's own tree, so the + // whole-folder hash has to survive alongside — scoping it away would re-break the + // clean install this check exists to recognise (#11220). + const upstream = await temporarySkill() + await writeFile(join(upstream, 'SKILL.md'), 'skill\n') + await mkdir(join(upstream, 'references'), { recursive: true }) + await writeFile(join(upstream, 'references', 'new.md'), 'shipped upstream\n') + const upstreamObserved = await observeSkillPackage(upstream) + // The lock is the source tree the CLI installed, derived independently of the + // observation under test — comparing the observation to itself would assert nothing. + const upstreamLock = (await observeSkillPackage(await copyOf(upstream))).observedGitTreeSha + + expect(officialPathsGitTreeSha(upstreamObserved, officialPaths)).not.toBe(upstreamLock) + expect(upstreamObserved.observedGitTreeSha).toBe(upstreamLock) + }) + + it('scopes the lock hash to the current bundle, not every path ever shipped', async () => { + // A file an older revision shipped and the current one dropped is a stale leftover, + // not part of what the updater installed. Scoping to the union of all revisions would + // drag it back into the hash purely because its name was once official, and the copy + // would read "may be modified" over bytes the CLI itself wrote. + const source = await temporarySkill() + await writeFile(join(source, 'SKILL.md'), 'skill\n') + const lock = (await observeSkillPackage(source)).observedGitTreeSha + + const withLeftover = await temporarySkill() + await writeFile(join(withLeftover, 'SKILL.md'), 'skill\n') + await mkdir(join(withLeftover, 'references'), { recursive: true }) + await writeFile(join(withLeftover, 'references', 'legacy.md'), 'dropped after rev 1\n') + const observed = await observeSkillPackage(withLeftover) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe(lock) + // The union of every revision's paths — what scoping must NOT use. + expect( + officialPathsGitTreeSha(observed, new Set(['SKILL.md', 'references/legacy.md'])) + ).not.toBe(lock) + }) + + it('does not hand the lock an empty tree when no official path is present', async () => { + // git's empty-tree sha is a real, matchable value; returning it would let a lock that + // ever recorded an empty source tree vouch for any folder holding nothing official. + const root = await temporarySkill() + await mkdir(join(root, 'agents'), { recursive: true }) + await writeFile(join(root, 'agents', 'openai.yaml'), 'display_name: test\n') + const observed = await observeSkillPackage(root) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe( + observed.observedGitTreeSha + ) + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).not.toBe( + '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + ) }) it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 4c883a2d6e6..5610479a4f7 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -14,8 +14,13 @@ type ObservedSkillFile = SkillBundleFileIdentity export type ObservedSkillPackage = { files: ObservedSkillFile[] observedDigest: string - /** Git tree sha of the raw bytes — comparable against the updater lock's skillFolderHash. */ + /** + * Git tree sha of every observed file's raw bytes — one of the two values comparable + * against the updater lock's `skillFolderHash` (see `officialPathsGitTreeSha`). + */ observedGitTreeSha: string + /** Retained so a subset of the observed paths can be re-hashed the same way. */ + treeEntries: SkillGitTreeFileEntry[] } // Why: package identity compares a live user directory against a tree the generator read @@ -250,21 +255,40 @@ export async function observeSkillPackage( return { files, observedDigest: skillPackageDigest(files), - observedGitTreeSha: skillPackageGitTreeSha(treeEntries) + observedGitTreeSha: skillPackageGitTreeSha(treeEntries), + treeEntries } } +/** + * The newest revision whose every listed file is present and byte-identical. + * + * `officialPaths` is what the CURRENT bundle says this skill owns, and it is what + * separates a tolerable neighbour from drift. Agent CLIs drop their own metadata + * beside an official SKILL.md (Codex writes `agents/openai.yaml` and cannot put it + * anywhere else), and a file no revision claims is not evidence the user edited + * anything — so extras alone must not mark the package unrecognized. + * + * A file the current bundle DOES list is never an extra, even when the revision + * being tested predates it: without that guard an older snapshot would happily match + * a folder whose newer official file had been tampered with, laundering real drift + * into a clean "outdated" row. Listed-file drift still fails closed either way. + */ export function matchingKnownSnapshot( observed: ObservedSkillPackage, - snapshots: readonly SkillKnownSnapshot[] + snapshots: readonly SkillKnownSnapshot[], + officialPaths: ReadonlySet ): SkillKnownSnapshot | null { + const observedByPath = new Map(observed.files.map((file) => [file.path, file])) for (const snapshot of snapshots.toReversed()) { - if (snapshot.files.length !== observed.files.length) { - continue - } + const listed = new Set(snapshot.files.map((file) => file.path)) + const launders = observed.files.some( + (file) => !listed.has(file.path) && officialPaths.has(file.path) + ) if ( - snapshot.files.every((expected, index) => { - const actual = observed.files[index] + !launders && + snapshot.files.every((expected) => { + const actual = observedByPath.get(expected.path) return Boolean(actual && matchesFileIdentity(actual, expected)) }) ) { @@ -273,3 +297,32 @@ export function matchingKnownSnapshot( } return null } + +/** + * The observed folder hashed as if it held only the files the current bundle owns. + * + * Compared against the updater lock's `skillFolderHash` ALONGSIDE the whole-folder + * hash, never instead of it. The two cover different halves and neither subsumes the + * other: the lock records the source tree, so a folder carrying a sidecar only ever + * matches scoped, while an upstream revision that ADDS a file puts that file in the + * lock's own tree and only ever matches whole. Publishing one and dropping the other + * would trade this bug for #11220 — a clean install reading "may be modified" and its + * update run reporting failure. + * + * Scoped to the CURRENT entry rather than every revision ever shipped: a leftover from + * an older revision is exactly the stale byte the lock comparison should look past, and + * unioning historical paths would drag it back in on the accident of its name. + * + * A folder holding none of them yields the whole-folder hash rather than git's empty-tree + * sha, which is a real, matchable value: a lock that ever recorded an empty source tree + * would otherwise vouch for every such folder. + */ +export function officialPathsGitTreeSha( + observed: ObservedSkillPackage, + officialPaths: ReadonlySet +): string { + const scoped = observed.treeEntries.filter((entry) => officialPaths.has(entry.path)) + return scoped.length === 0 || scoped.length === observed.treeEntries.length + ? observed.observedGitTreeSha + : skillPackageGitTreeSha(scoped) +} diff --git a/src/main/skills/skill-update-convergence.test.ts b/src/main/skills/skill-update-convergence.test.ts index 86e4f395e6d..3e8d2633a06 100644 --- a/src/main/skills/skill-update-convergence.test.ts +++ b/src/main/skills/skill-update-convergence.test.ts @@ -2,13 +2,22 @@ import { describe, expect, it } from 'vitest' import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness' import { convergableSkillNames } from './skill-update-convergence' +// `installedReleaseRevision` is the revision observation resolved this copy to, and +// null is the honest "readable, but matching nothing we know". A null +// `observedPackageDigest` is the different case of a copy that could not be read. +// +// The digest defaults to the resolved revision's own, so these fixtures describe a +// folder holding nothing but official files. That keeps the revert signal honest: +// swapping resolution back to a digest lookup still passes every case below except +// the sidecar one, which is the only case that distinguishes the two mechanisms. function placement( name: string, - observedPackageDigest: string | null, - topology: SkillFreshnessInstallation['topology'] = 'canonical-copy' + installedReleaseRevision: number | null, + topology: SkillFreshnessInstallation['topology'] = 'canonical-copy', + observedPackageDigest: string | null = `digest-${installedReleaseRevision}` ): SkillFreshnessInstallation { return { - id: `${name}:${observedPackageDigest}:${topology}`, + id: `${name}:${installedReleaseRevision}:${topology}`, name, rootId: 'home', providers: [], @@ -19,7 +28,7 @@ function placement( physicalIdentity: '1:1', topology, status: 'outdated', - installedReleaseRevision: null, + installedReleaseRevision, installedAppVersion: null, currentReleaseRevision: 8, currentPackageDigest: 'digest-current', @@ -29,24 +38,22 @@ function placement( } } -function revision(packageDigest: string, gitTreeSha: string): SkillKnownSnapshot { - return { releaseRevision: 1, packageDigest, gitTreeSha, files: [] } +function revision(releaseRevision: number, gitTreeSha: string): SkillKnownSnapshot { + return { releaseRevision, packageDigest: `digest-${releaseRevision}`, gitTreeSha, files: [] } } +const PRE_STUB = revision(1, 'f3727995') +const STUB = revision(2, '091d9bcc') + describe('convergableSkillNames', () => { // The real reported case: the lock records the stub tree (091d9bcc) while disk // still holds the pre-stub revision (f3727995). `skills update` compares lock to // source, sees no work, exits 0 and writes nothing — forever. it('drops a skill whose lock records a revision the disk does not have', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub')], + [placement('orca-linear', 1)], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -55,42 +62,50 @@ describe('convergableSkillNames', () => { // has simply moved ahead of what this build bundles. The update really can converge. it('keeps a skill whose lock matches disk even when it is outdated', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill whose disk content matches no known revision', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-other', 'bbbb2222')] } + { 'orca-cli': [revision(1, 'bbbb2222')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill with no observable placement', () => { const result = convergableSkillNames( - [placement('orca-cli', null)], + [placement('orca-cli', null, 'canonical-copy', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) + // Why: a sidecar an agent CLI dropped beside the official files makes the folder + // digest match no revision at all, so resolving disk content by that digest would + // read as unidentifiable and quietly re-arm the unwinnable update. Observation + // already placed this copy at the pre-stub revision; the gate must honour that. + it('drops a stale skill whose folder holds files no revision lists', () => { + const result = convergableSkillNames( + [placement('orca-linear', 1, 'canonical-copy', 'digest-with-sidecar')], + new Map([['orca-linear', '091d9bcc']]), + { 'orca-linear': [PRE_STUB, STUB] } + ) + expect([...result]).toEqual([]) + }) + // One placement still matching the lock means the command has an anchor to write. it('keeps a skill when any placement still matches the lock', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed'), placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1), placement('orca-cli', 2)], new Map([['orca-cli', 'aaaa1111']]), - { - 'orca-cli': [ - revision('digest-installed', 'aaaa1111'), - revision('digest-pre-stub', 'f3727995') - ] - } + { 'orca-cli': [revision(1, 'aaaa1111'), revision(2, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -98,23 +113,21 @@ describe('convergableSkillNames', () => { // A lock hash we cannot place is not evidence the command is stuck. it('keeps a skill whose lock names no revision we know', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'not-a-known-tree']]), - { 'orca-cli': [revision('digest-pre-stub', 'f3727995')] } + { 'orca-cli': [revision(1, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) - // Why: `diskTreeShas` silently drops digests that match no known revision, so a + // Why: `diskTreeShas` silently drops placements that resolved to nothing, so a // stale copy sitting beside an unidentifiable one must NOT gate the name — the // unknown half could be anything, including a copy the command would converge. it('keeps a skill when one placement is stale but another is unidentifiable', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub'), placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', 1), placement('orca-cli', null)], new Map([['orca-cli', '091d9bcc']]), - { - 'orca-cli': [revision('digest-pre-stub', 'f3727995'), revision('digest-stub', '091d9bcc')] - } + { 'orca-cli': [PRE_STUB, STUB] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -124,17 +137,9 @@ describe('convergableSkillNames', () => { // placement and re-arm the unwinnable update on the drifted canonical. it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-cache-repack', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', null, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -143,34 +148,23 @@ describe('convergableSkillNames', () => { // the command only writes the canonical, which is still drifted. it('ignores a plugin-cache copy that matches the lock', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-stub', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', 2, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) it('judges each locked skill independently', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub'), placement('orca-cli', 'digest-installed')], + [placement('orca-linear', 1), placement('orca-cli', 1)], new Map([ ['orca-linear', '091d9bcc'], ['orca-cli', 'aaaa1111'] ]), { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ], - 'orca-cli': [revision('digest-installed', 'aaaa1111')] + 'orca-linear': [PRE_STUB, STUB], + 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) diff --git a/src/main/skills/skill-update-convergence.ts b/src/main/skills/skill-update-convergence.ts index 41889058f3f..c318e89d8b8 100644 --- a/src/main/skills/skill-update-convergence.ts +++ b/src/main/skills/skill-update-convergence.ts @@ -34,20 +34,27 @@ export function convergableSkillNames( // it must neither gate the name nor rescue it — an unidentifiable cache copy // (or one parked at the lock's own revision) would otherwise defeat the gate // and re-arm the unwinnable update. - const digests = installations - .filter( - (entry) => - entry.name === name && - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && - entry.observedPackageDigest - ) - .map((entry) => entry.observedPackageDigest) - if (digests.length === 0) { + const observable = installations.filter( + (entry) => + entry.name === name && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && + entry.observedPackageDigest + ) + if (observable.length === 0) { continue } const revisions = knownSnapshots[name] ?? [] - const diskTreeShas = digests - .map((digest) => revisions.find((revision) => revision.packageDigest === digest)?.gitTreeSha) + // Why: the revision each placement resolved to during observation, not a fresh + // lookup by whole-folder digest. Identity tolerates files the manifest never + // listed, so a folder holding an agent CLI's sidecar digests to nothing any + // revision knows — re-deriving here would call every such placement + // unidentifiable and quietly retire the gate for the users who most need it. + const diskTreeShas = observable + .map( + (entry) => + revisions.find((revision) => revision.releaseRevision === entry.installedReleaseRevision) + ?.gitTreeSha + ) .filter((sha): sha is string => Boolean(sha)) // Why: only claim the lock is stale when BOTH sides are positively identified — // the lock names a revision we know, and every placement resolves to a different @@ -55,11 +62,11 @@ export function convergableSkillNames( // revision older than the registry) is not evidence of anything, and gating on it // would withhold updates that would have worked. const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash) - // `diskTreeShas` drops digests that match no known revision, so requiring every - // observed digest to resolve is what keeps `every` honest: without it, one stale - // copy beside one unidentifiable copy would gate the name off the resolved half - // alone, contradicting the unknown-stays-eligible rule above. - const everyPlacementResolved = diskTreeShas.length === digests.length + // `diskTreeShas` drops placements that resolved to no known revision, so requiring + // every observable one to resolve is what keeps `every` honest: without it, one + // stale copy beside one unidentifiable copy would gate the name off the resolved + // half alone, contradicting the unknown-stays-eligible rule above. + const everyPlacementResolved = diskTreeShas.length === observable.length if ( lockNamesAKnownRevision && everyPlacementResolved && diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 9a15b856644..9c032cb9f2f 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -2,6 +2,7 @@ import { SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, type SkillFreshnessInstallation } from '../../shared/skill-freshness' +import { matchesUpdaterLock } from './skill-update-registration' /** * Names that did not land, judged from the post-run inventory. @@ -53,9 +54,5 @@ function skillPlacementLanded( // yet. Everything else unrecognized (half-written, no lock entry, mismatch) // still fails, and `outdated` is never forgiven: lock == disk there means the // command provably wrote nothing. - return ( - entry.status === 'unrecognized' && - lockHash !== undefined && - entry.observedGitTreeSha === lockHash - ) + return entry.status === 'unrecognized' && matchesUpdaterLock(entry, lockHash) } diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts index 66231d71fbe..e80b8dc0580 100644 --- a/src/main/skills/skill-update-registration.ts +++ b/src/main/skills/skill-update-registration.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import type { SkillFreshnessInstallation } from '../../shared/skill-freshness' const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3 @@ -21,6 +22,32 @@ function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { : join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json') } +/** + * Whether a placement holds exactly the bytes the updater recorded installing. + * + * Either hash may carry it. The lock is the git tree sha of the source folder, so a + * folder an agent CLI dropped a sidecar into only matches once that sidecar is scoped + * out, while an upstream revision that added a file only matches whole — that file is + * in the lock's own tree but in no bundled snapshot. Requiring one specific hash trades + * either #12694 or #11220 for the other. + * + * Not exhaustive, and deliberately so: a folder carrying BOTH a sidecar and an upstream + * file no bundle lists matches neither hash and stays unrecognized, exactly as it does + * without this pair. Closing that needs the sidecar gone before hashing — an observe-time + * exclusion like `isOsMetadataSkillEntryName`, which means naming the foreign paths rather + * than tolerating any unlisted one. Editing a listed file still matches neither hash. + */ +export function matchesUpdaterLock( + installation: SkillFreshnessInstallation, + lockHash: string | undefined +): boolean { + return ( + lockHash !== undefined && + (installation.observedGitTreeSha === lockHash || + installation.observedOfficialGitTreeSha === lockHash) + ) +} + export async function readGloballyUpdatableSkillNames( args: SkillUpdateRegistrationArgs = {} ): Promise> { diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 24bbcc9df38..dafc504727d 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -83,6 +83,13 @@ export type SkillFreshnessInstallation = { observedPackageDigest: string | null /** Git tree sha of the observed bytes; lets the post-run verdict match disk against the updater's lock. */ observedGitTreeSha?: string | null + /** + * The same hash over only the files the current bundle lists. Carried beside the + * whole-folder hash, not in place of it, so a folder holding an agent CLI's sidecar + * can still match the lock without blinding the check to an upstream revision that + * added a file. Absent from hosts older than this field. + */ + observedOfficialGitTreeSha?: string | null errorCategory: string | null } From a30e3b9f61e51e78b0afea555b94619f87ed0592 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:57:16 -0700 Subject: [PATCH 36/88] feat(dashboard): add experimental agent map view (#12168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add experimental agent map view * fix(dashboard): harden agent map behavior * fix(dashboard): harden agent map recovery * fix(dashboard): close map selection on view change * fix(agent-map): center sparse layouts * fix(agent-map): align completion and workspace actions * Polish agent map interactions and repo labels * feat(agent-map): add worktree lineage and project actions * fix(agent-map): use marker for unread agents * fix(agent-map): compact orchestrated families * fix(dashboard): harden agent map actions and layout * fix(agent-map): bound layout work and preserve interactions * fix(agent-map): move unread marker to ring top-right * fix(agent-map): seat unread marker on the ring's top-left edge * feat(agent-map): restore the agent launcher and declutter map labels Three gaps in the experimental Agent Map: - The "start a new agent" picker was split onto a preserved branch during the 08-02 rebase (47829cb226) and never re-landed. Restores that commit and its pop-out IPC, keyed on the raw worktree id rather than the map identity. - Workspace labels draw at a fixed screen size with no collision handling, so a zoomed-out map stacked dozens of names on each other. Adds a declutter pass that seats project names first, then workspace names by attention, then project counts in whatever room is left. - The pop-out had no workspace right-click at all: its renderer has no store, so the shared sidebar menu cannot mount there. Adds a snapshot-driven menu with the launcher and Sleep, relayed to the main renderer. * refactor(agent-map): fold the map's filter rail into the shared toolbar filter The rail duplicated the toolbar's project filter and cost the canvas 14rem of width on the surface that needs it most. Agent states move into the toolbar's Filter dropdown (map view only — the board's columns already separate them) and count toward its badge; project filtering falls back to the toolbar's own. Show all is the dropdown's Clear all, and Fit already lives in the viewport controls. * fix(agent-map): isolate map work from main renderer * perf(agent-map): stream status updates to popout * fix(i18n): add agent map catalog entries * feat(agent-map): glow working entities * fix(agent-map): prioritize attention ring status * fix(agent-map): distinguish subagent connectors --- src/main/index.ts | 33 +- .../ipc/dashboard-agent-launch-validation.ts | 40 + .../dashboard-filter-payload-validation.ts | 44 + .../ipc/dashboard-payload-validation.test.ts | 119 +++ src/main/ipc/dashboard-payload-validation.ts | 78 +- src/main/ipc/dashboard-popout.test.ts | 34 + src/main/ipc/dashboard-popout.ts | 35 +- .../dashboard-workspace-payload-validation.ts | 80 ++ .../window/dashboard-popout-window.test.ts | 16 + src/main/window/dashboard-popout-window.ts | 11 +- src/preload/api-types.ts | 14 +- src/preload/index.ts | 36 +- src/renderer/src/App.tsx | 9 +- .../AgentDashboardFilterChips.tsx | 13 + .../AgentDashboardMapView.tsx | 129 +++ .../AgentDashboardToolbar.tsx | 112 ++- .../AgentKanbanBoard.test.tsx | 252 +++++- .../dashboard-popout/AgentKanbanBoard.tsx | 216 ++++- .../dashboard-popout/AgentKanbanCard.test.tsx | 6 +- .../dashboard-popout/AgentKanbanCard.tsx | 10 +- .../dashboard-popout/AgentMap.test.tsx | 752 ++++++++++++++++++ .../components/dashboard-popout/AgentMap.tsx | 182 +++++ .../AgentMapCanvas.performance.test.tsx | 95 +++ .../dashboard-popout/AgentMapCanvas.tsx | 415 ++++++++++ .../AgentMapProjectContextMenu.tsx | 114 +++ .../AgentMapProjectContextMenuLoader.tsx | 29 + .../AgentMapProjectLabel.test.tsx | 60 ++ .../dashboard-popout/AgentMapScene.tsx | 243 ++++++ .../AgentMapSnapshotWorkspaceMenu.tsx | 100 +++ .../AgentMapViewportControls.tsx | 47 ++ ...ntMapWorkspaceContextMenu.boundary.test.ts | 35 + .../AgentMapWorkspaceContextMenu.test.tsx | 375 +++++++++ .../AgentMapWorkspaceContextMenu.tsx | 167 ++++ .../AgentMapWorkspaceContextMenuLoader.tsx | 35 + .../AgentMapWorktreeLabel.tsx | 39 + .../AgentMapWorktreeRingNode.tsx | 370 +++++++++ .../AgentTerminalDialog.test.tsx | 87 +- .../dashboard-popout/AgentTerminalDialog.tsx | 195 +++-- .../dashboard-popout/AgentTerminalPreview.tsx | 9 +- .../dashboard-popout/DashboardPopoutRoot.tsx | 12 +- .../dashboard-popout/agent-board-filtering.ts | 45 +- .../agent-map-agent-placement.ts | 54 ++ .../dashboard-popout/agent-map-filter.test.ts | 77 ++ .../dashboard-popout/agent-map-filter.ts | 48 ++ .../agent-map-glow.performance.test.ts | 32 + .../agent-map-label-declutter.test.ts | 164 ++++ .../agent-map-label-declutter.ts | 273 +++++++ .../agent-map-layout-metadata.ts | 53 ++ .../dashboard-popout/agent-map-layout.test.ts | 604 ++++++++++++++ .../dashboard-popout/agent-map-layout.ts | 308 +++++++ .../agent-map-lineage-layout.ts | 224 ++++++ .../agent-map-navigation.test.ts | 42 + .../dashboard-popout/agent-map-navigation.ts | 53 ++ .../agent-map-node-metadata.ts | 17 + .../agent-map-packing-spatial-index.ts | 94 +++ .../agent-map-project-placement.ts | 50 ++ .../agent-map-spawn-clustering.ts | 27 + .../agent-map-viewport-transition.ts | 56 ++ .../agent-map-workspace-identity.ts | 29 + .../agent-map-workspace-visibility.test.ts | 81 ++ .../agent-map-workspace-visibility.ts | 25 + .../agent-map-worktree-active-status.test.ts | 21 + .../agent-map-worktree-active-status.ts | 15 + .../agent-map-worktree-lineage-layout.test.ts | 223 ++++++ .../agent-map-worktree-lineage-layout.ts | 279 +++++++ .../agent-map-worktree-packing.test.ts | 168 ++++ .../agent-map-worktree-packing.ts | 267 +++++++ .../components/dashboard-popout/agent-map.css | 335 ++++++++ .../dashboard-agent-status-patch.test.ts | 111 +++ .../dashboard-agent-status-patch.ts | 83 ++ .../dashboard-popout/useAgentMapCanvasSize.ts | 35 + .../useAgentMapContextMenus.tsx | 156 ++++ .../useAgentMapSelectedFocus.ts | 63 ++ .../useAgentMapStateFilter.ts | 29 + .../useAgentMapViewportTransition.ts | 51 ++ .../useDashboardSnapshot.test.tsx | 100 ++- .../dashboard-popout/useDashboardSnapshot.ts | 96 ++- .../dashboard/AgentDashboardDrawer.test.tsx | 137 ++++ .../dashboard/AgentDashboardDrawer.tsx | 11 +- .../dashboard/DashboardPopoutBridge.tsx | 7 + ...nt-dashboard-performance-isolation.test.ts | 36 + .../components/dashboard/agent-row-lineage.ts | 10 + ...ashboard-snapshot-folder-workspace.test.ts | 144 ++++ .../build-dashboard-snapshot.test.ts | 210 ++++- .../dashboard/build-dashboard-snapshot.ts | 185 ++--- .../dashboard/dashboard-card-bucket.ts | 15 + .../dashboard/dashboard-card-context.ts | 6 +- .../dashboard/dashboard-card-labels.ts | 43 + .../dashboard-snapshot-filter-options.ts | 27 + .../dashboard-snapshot-workspaces.ts | 124 +++ .../dashboard-worktree-launch-options.test.ts | 132 +++ .../dashboard-worktree-launch-options.ts | 127 +++ .../dashboard/launch-dashboard-agent.test.ts | 46 ++ .../dashboard/launch-dashboard-agent.ts | 23 + .../dashboard/useAgentBucketCounts.test.tsx | 85 ++ .../dashboard/useAgentBucketCounts.ts | 10 +- .../components/dashboard/useDashboardData.ts | 1 + .../useDashboardPopoutBridge.test.tsx | 59 +- .../dashboard/useDashboardPopoutBridge.ts | 30 +- .../dashboard/useLiveDashboardSnapshot.ts | 9 + .../settings/ExperimentalPane.test.tsx | 2 +- .../sidebar/AgentDashboardSidebarEntry.tsx | 90 +++ .../AgentDashboardSidebarHost.test.tsx | 75 ++ .../sidebar/AgentDashboardSidebarHost.tsx | 43 + .../src/components/sidebar/Sidebar.test.tsx | 59 -- .../components/sidebar/SidebarNav.test.tsx | 14 +- .../src/components/sidebar/SidebarNav.tsx | 124 +-- .../sidebar/WorktreeContextMenu.tsx | 41 +- src/renderer/src/components/sidebar/index.tsx | 39 +- src/renderer/src/i18n/locales/en.json | 41 + src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- src/renderer/src/store/selectors.ts | 15 +- src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 4 - src/shared/dashboard-snapshot.ts | 67 ++ 118 files changed, 10715 insertions(+), 516 deletions(-) create mode 100644 src/main/ipc/dashboard-agent-launch-validation.ts create mode 100644 src/main/ipc/dashboard-filter-payload-validation.ts create mode 100644 src/main/ipc/dashboard-workspace-payload-validation.ts create mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapScene.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map.css create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts create mode 100644 src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx create mode 100644 src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx create mode 100644 src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-bucket.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-labels.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts create mode 100644 src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts create mode 100644 src/renderer/src/components/dashboard/launch-dashboard-agent.ts create mode 100644 src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx diff --git a/src/main/index.ts b/src/main/index.ts index ebabeb2b18e..790a8a2ed69 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -188,7 +188,10 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window' +import { + getDashboardPopoutWindow, + zoomDashboardPopoutIfFocused +} from './window/dashboard-popout-window' import { createSystemTray, destroySystemTray, @@ -303,6 +306,7 @@ import { } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' +import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { HEADLESS_RUNTIME_WINDOW_ID, @@ -1450,7 +1454,16 @@ function openMainWindow(): BrowserWindow { maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - mainWindow?.webContents.send('agentStatus:set', { + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { ...payload, paneKey, ...(launchToken ? { launchToken } : {}), @@ -1464,19 +1477,14 @@ function openMainWindow(): BrowserWindow { ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) - }) + } + mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. const profile = getSyntheticAgentTitleProfile(payload.agentType) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false if ( profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && @@ -1491,6 +1499,7 @@ function openMainWindow(): BrowserWindow { return } mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) }) setMigrationUnsupportedPtyListener((event) => { if (mainWindow?.isDestroyed()) { diff --git a/src/main/ipc/dashboard-agent-launch-validation.ts b/src/main/ipc/dashboard-agent-launch-validation.ts new file mode 100644 index 00000000000..c50e72f6ec3 --- /dev/null +++ b/src/main/ipc/dashboard-agent-launch-validation.ts @@ -0,0 +1,40 @@ +import { + DASHBOARD_MAX_LAUNCH_WORKTREES, + type DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../shared/tui-agent-config' + +const MAX_ID_LENGTH = 4_096 +const MAX_AGENTS_PER_WORKTREE = 64 + +function isBoundedId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH +} + +export function isDashboardSpawnAgentArgs(value: unknown): value is DashboardSpawnAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return isBoundedId(args.worktreeId) && isTuiAgent(args.agent) +} + +export function isDashboardLaunchOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= DASHBOARD_MAX_LAUNCH_WORKTREES && + entries.every( + ([worktreeId, agents]) => + isBoundedId(worktreeId) && + Array.isArray(agents) && + agents.length <= MAX_AGENTS_PER_WORKTREE && + agents.every(isTuiAgent) + ) + ) +} diff --git a/src/main/ipc/dashboard-filter-payload-validation.ts b/src/main/ipc/dashboard-filter-payload-validation.ts new file mode 100644 index 00000000000..694d2db2717 --- /dev/null +++ b/src/main/ipc/dashboard-filter-payload-validation.ts @@ -0,0 +1,44 @@ +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../shared/dashboard-snapshot' + +const MAX_DASHBOARD_FILTER_OPTIONS = 500 +const MAX_ID_LENGTH = 4_096 + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalBoundedString(value: unknown, maxLength: number): boolean { + return value === undefined || isBoundedString(value, maxLength, true) +} + +function isDashboardFilterOptionList(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_DASHBOARD_FILTER_OPTIONS && + value.every((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false + } + const option = entry as Record + return ( + isBoundedString(option.id, MAX_ID_LENGTH) && + isBoundedString(option.label, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalBoundedString(option.color, MAX_ID_LENGTH) + ) + }) + ) +} + +export function isDashboardFilterOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const options = value as Record + return ( + isDashboardFilterOptionList(options.projects) && + isDashboardFilterOptionList(options.workspaceStatuses) + ) +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 954723a6182..f377ad3c9de 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -19,6 +19,7 @@ vi.mock('../../shared/repo-icon', async (importOriginal) => { import { admitDashboardSnapshot, isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs, isDashboardSnapshot } from './dashboard-payload-validation' @@ -38,8 +39,13 @@ const SNAPSHOT = { worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', + parentPaneKey: 'tab-parent:leaf-parent', + parentWorktreeId: 'parent-worktree-1', repoName: 'Orca', worktreeName: 'Dashboard', + hostKind: 'ssh', + executionHostId: 'ssh:build-box', + workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', workspaceStatusColor: 'emerald', @@ -53,6 +59,23 @@ const SNAPSHOT = { askSummary: '{"question":"Proceed?"}' } ], + workspaces: [ + { + repoId: 'repo-1', + worktreeId: 'worktree-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + parentWorktreeId: 'parent-worktree-1', + hostKind: 'ssh', + executionHostId: 'ssh:build-box', + workspaceKind: 'worktree', + workspaceStatusId: 'in-review', + workspaceStatusLabel: 'In review', + workspaceStatusColor: 'emerald', + hasReview: true, + review: { number: 11012, state: 'open' } + } + ], showIdle: false, filterOptions: { projects: [{ id: 'repo-1', label: 'Orca' }], @@ -102,6 +125,42 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], subagents: [{ id: '', name: 'bad', dotState: 'idle' }] }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], hostKind: 'satellite' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], executionHostId: 'build-box' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], parentWorktreeId: 'x'.repeat(4_097) }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], workspaceKind: 'repository' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], parentPaneKey: 'x'.repeat(4_097) }] + }) + ).toBe(false) }) it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { @@ -162,6 +221,43 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('validates bounded map workspace metadata independently of cards', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [] })).toBe(true) + expect(isDashboardSnapshot({ ...SNAPSHOT, workspaces: undefined })).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], executionHostId: 'build-box' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }] + }) + ).toBe(false) + }) + + it('validates bounded launch choices and spawn requests', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['codex', 'claude'] } + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['not-an-agent'] } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, launchableAgentsByWorktreeId: [] })).toBe(false) + + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'codex' })).toBe(true) + expect(isDashboardSpawnAgentArgs({ worktreeId: '', agent: 'codex' })).toBe(false) + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'unknown' })).toBe(false) + }) + it('bounds the conversation name', () => { expect( isDashboardSnapshot({ @@ -241,6 +337,19 @@ describe('dashboard payload validation', () => { expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1']) }) + it('drops only malformed optional workspace metadata', () => { + const admitted = admitDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [ + SNAPSHOT.workspaces[0], + { ...SNAPSHOT.workspaces[0], worktreeId: '', worktreeName: 'Invalid' } + ] + }) + + expect(admitted?.droppedCardCount).toBe(0) + expect(admitted?.snapshot.workspaces).toEqual([SNAPSHOT.workspaces[0]]) + }) + it('reports nothing dropped for a fully valid snapshot', () => { const admitted = admitDashboardSnapshot(SNAPSHOT) @@ -406,10 +515,20 @@ describe('dashboard payload validation', () => { isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', + executionHostId: 'runtime:env-1', tabId: 'tab-1', leafId: null }) ).toBe(true) + expect( + isDashboardRevealAgentArgs({ + repoId: 'repo-1', + worktreeId: 'worktree-1', + executionHostId: 'runtime:', + tabId: 'tab-1', + leafId: null + }) + ).toBe(false) expect( isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' }) ).toBe(false) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 0dfb80648d9..f1cc0b66b7c 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -1,9 +1,11 @@ import { DASHBOARD_MAX_LABEL_LENGTH, type DashboardRevealAgentArgs, + type DashboardSleepWorkspaceArgs, type DashboardSnapshot } from '../../shared/dashboard-snapshot' import { BoundedMap } from '../../shared/bounded-map' +import { normalizeExecutionHostId } from '../../shared/execution-host' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, @@ -11,11 +13,17 @@ import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_TYPE_MAX_LENGTH } from '../../shared/agent-status-types' +import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation' +import { + admitDashboardWorkspaces, + isDashboardWorkspaceList +} from './dashboard-workspace-payload-validation' +import { isDashboardFilterOptions } from './dashboard-filter-payload-validation' +export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_SUBAGENTS = 100 const MAX_DASHBOARD_REPO_ICONS = 500 -const MAX_DASHBOARD_FILTER_OPTIONS = 500 // Why: sanitizing an image icon base64-decodes the whole data URI to read a // 24-byte header, and the renderer republishes the same icons every 250 ms. const MAX_CACHED_ICON_SRC_BYTES = 8 * 1024 * 1024 @@ -28,6 +36,8 @@ const MAX_ID_LENGTH = 4_096 const MAX_LABEL_LENGTH = DASHBOARD_MAX_LABEL_LENGTH const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'done', 'idle']) const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle']) +const DASHBOARD_HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const DASHBOARD_WORKSPACE_KINDS = new Set(['worktree', 'folder']) const DASHBOARD_REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) const DASHBOARD_HOST_PLATFORMS = new Set([ 'aix', @@ -64,6 +74,9 @@ export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRe return ( isBoundedString(args.repoId, MAX_ID_LENGTH) && isBoundedString(args.worktreeId, MAX_ID_LENGTH) && + (args.executionHostId === undefined || + (isBoundedString(args.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(args.executionHostId) !== null)) && isBoundedString(args.tabId, MAX_ID_LENGTH) && (args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) ) @@ -73,6 +86,15 @@ export function isDashboardPaneKey(value: unknown): value is string { return isBoundedString(value, MAX_ID_LENGTH) } +export function isDashboardSleepWorkspaceArgs( + value: unknown +): value is DashboardSleepWorkspaceArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + return isBoundedString((value as Record).worktreeId, MAX_ID_LENGTH) +} + export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -83,8 +105,10 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && snapshot.cards.every(isDashboardCard) && + isDashboardWorkspaceList(snapshot.workspaces) && (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && isDashboardFilterOptions(snapshot.filterOptions) && + isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) && isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) } @@ -107,55 +131,30 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi return null } const snapshot = value as Record + const workspaces = admitDashboardWorkspaces(snapshot.workspaces) if ( !isFiniteNumber(snapshot.generatedAt) || !Array.isArray(snapshot.cards) || snapshot.cards.length > MAX_DASHBOARD_CARDS || + workspaces === null || (snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') || !isDashboardFilterOptions(snapshot.filterOptions) || + !isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) || !isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) { return null } const cards = snapshot.cards.filter(isDashboardCard) return { - snapshot: { ...(snapshot as unknown as DashboardSnapshot), cards }, + snapshot: { + ...(snapshot as unknown as DashboardSnapshot), + cards, + ...(workspaces ? { workspaces } : {}) + }, droppedCardCount: snapshot.cards.length - cards.length } } -function isDashboardFilterOptions(value: unknown): boolean { - if (value === undefined) { - return true - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const options = value as Record - return ( - isDashboardFilterOptionList(options.projects) && - isDashboardFilterOptionList(options.workspaceStatuses) - ) -} - -function isDashboardFilterOptionList(value: unknown): boolean { - return ( - Array.isArray(value) && - value.length <= MAX_DASHBOARD_FILTER_OPTIONS && - value.every((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - return false - } - const option = entry as Record - return ( - isBoundedString(option.id, MAX_ID_LENGTH) && - isBoundedString(option.label, MAX_LABEL_LENGTH, true) && - isOptionalBoundedString(option.color, MAX_ID_LENGTH) - ) - }) - ) -} - /** Repo icons reach the pop-out's ``, so each one must survive the * same sanitizer the settings picker writes through. */ function isDashboardRepoIcons(value: unknown): boolean { @@ -264,8 +263,18 @@ function isDashboardCard(value: unknown): boolean { isBoundedString(card.worktreeId, MAX_ID_LENGTH) && isBoundedString(card.tabId, MAX_ID_LENGTH) && (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && + isOptionalBoundedString(card.parentPaneKey, MAX_ID_LENGTH) && + isOptionalBoundedString(card.parentWorktreeId, MAX_ID_LENGTH) && isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + (card.hostKind === undefined || + (typeof card.hostKind === 'string' && DASHBOARD_HOST_KINDS.has(card.hostKind))) && + (card.executionHostId === undefined || + (isBoundedString(card.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(card.executionHostId) !== null)) && + (card.workspaceKind === undefined || + (typeof card.workspaceKind === 'string' && + DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) && isOptionalBoundedString(card.workspaceStatusId, MAX_ID_LENGTH) && isOptionalBoundedString(card.workspaceStatusLabel, MAX_LABEL_LENGTH) && isOptionalBoundedString(card.workspaceStatusColor, MAX_ID_LENGTH) && @@ -275,6 +284,7 @@ function isDashboardCard(value: unknown): boolean { isFiniteNumber(card.startedAt) && (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && + (card.statusUpdatedAt === undefined || isFiniteNumber(card.statusUpdatedAt)) && typeof card.unseen === 'boolean' && isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) && diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index d516faa4554..ce10b3556fc 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -127,6 +127,15 @@ describe('registerDashboardPopoutHandlers', () => { expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, { getKeybindings: expect.any(Function) }) + + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'map') + expect(createPopoutMock).toHaveBeenLastCalledWith(store, 'map', { + getKeybindings: expect.any(Function) + }) + + createPopoutMock.mockClear() + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'invalid') + expect(createPopoutMock).not.toHaveBeenCalled() }) it('auto-closes the popout when the feature is disabled', () => { @@ -240,6 +249,31 @@ describe('registerDashboardPopoutHandlers', () => { expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') }) + it('relays only valid agent launches from the popout', () => { + const args = { worktreeId: 'worktree-1', agent: 'codex' } + handlers.get('dashboardPopout:spawnAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, { + ...args, + agent: 'unknown' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:spawnDashboardAgent', args) + }) + + it('relays only valid sleep requests from the popout', () => { + const args = { worktreeId: 'worktree-1' } + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, { + worktreeId: '' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:sleepDashboardWorkspace', args) + }) + it('reveals an agent in only the trusted main window', () => { const main = makeWindow(mainSender) getTrustedWindowMock.mockReturnValue(main) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index c7a26421878..5b4781ec761 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -14,7 +14,9 @@ import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRendere import { admitDashboardSnapshot, isDashboardPaneKey, - isDashboardRevealAgentArgs + isDashboardRevealAgentArgs, + isDashboardSleepWorkspaceArgs, + isDashboardSpawnAgentArgs } from './dashboard-payload-validation' // The most recent snapshot the main renderer published, replayed to the popout @@ -36,6 +38,8 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') ipcMain.removeHandler('dashboardPopout:ackAgent') + ipcMain.removeHandler('dashboardPopout:spawnAgent') + ipcMain.removeHandler('dashboardPopout:sleepWorkspace') onDashboardPopoutOpenChanged((open) => { if (!open) { @@ -52,11 +56,14 @@ export function registerDashboardPopoutHandlers( } }) - ipcMain.handle('dashboardPopout:open', (event): void => { + ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => { if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { return } - createOrFocusDashboardPopout(store, undefined, { + if (view !== undefined && view !== 'board' && view !== 'map') { + return + } + createOrFocusDashboardPopout(store, view, { getKeybindings: () => keybindings?.getOverrides() }) }) @@ -141,4 +148,26 @@ export function registerDashboardPopoutHandlers( // Best-effort; the per-window focus above may still bring it forward. } }) + + ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSpawnAgentArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:spawnDashboardAgent', args) + }) + + ipcMain.handle('dashboardPopout:sleepWorkspace', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSleepWorkspaceArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:sleepDashboardWorkspace', args) + }) } diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts new file mode 100644 index 00000000000..88e16d9af45 --- /dev/null +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -0,0 +1,80 @@ +import { + DASHBOARD_MAX_LABEL_LENGTH, + DASHBOARD_MAX_MAP_WORKSPACES, + type DashboardWorkspace +} from '../../shared/dashboard-snapshot' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const MAX_ID_LENGTH = 4_096 +const HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const WORKSPACE_KINDS = new Set(['worktree', 'folder']) +const REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) + +function isString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalString(value: unknown, maxLength: number): boolean { + return value === undefined || isString(value, maxLength, true) +} + +function isReview(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const review = value as Record + return ( + typeof review.number === 'number' && + Number.isFinite(review.number) && + review.number > 0 && + typeof review.state === 'string' && + REVIEW_STATES.has(review.state) + ) +} + +export function isDashboardWorkspace(value: unknown): value is DashboardWorkspace { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const workspace = value as Record + return ( + isString(workspace.repoId, MAX_ID_LENGTH) && + isString(workspace.worktreeId, MAX_ID_LENGTH) && + isString(workspace.repoName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isString(workspace.worktreeName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalString(workspace.parentWorktreeId, MAX_ID_LENGTH) && + typeof workspace.hostKind === 'string' && + HOST_KINDS.has(workspace.hostKind) && + isString(workspace.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(workspace.executionHostId) !== null && + typeof workspace.workspaceKind === 'string' && + WORKSPACE_KINDS.has(workspace.workspaceKind) && + isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && + isOptionalString(workspace.workspaceStatusLabel, DASHBOARD_MAX_LABEL_LENGTH) && + isOptionalString(workspace.workspaceStatusColor, MAX_ID_LENGTH) && + (workspace.hasReview === undefined || typeof workspace.hasReview === 'boolean') && + isReview(workspace.review) + ) +} + +export function isDashboardWorkspaceList(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && + value.length <= DASHBOARD_MAX_MAP_WORKSPACES && + value.every(isDashboardWorkspace)) + ) +} + +export function admitDashboardWorkspaces(value: unknown): DashboardWorkspace[] | undefined | null { + if (value === undefined) { + return undefined + } + if (!Array.isArray(value) || value.length > DASHBOARD_MAX_MAP_WORKSPACES) { + return null + } + return value.filter(isDashboardWorkspace) +} diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0976a922e16..7ffa56d7b53 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -231,6 +231,12 @@ describe('createOrFocusDashboardPopout', () => { expect(options).toEqual({ search: 'view=kanban' }) }) + it('opens on the current dashboard view by default', () => { + createOrFocusDashboardPopout(makeStore() as never) + + expect(instances[0].loadFile.mock.calls[0][1]).toEqual({ search: 'view=board' }) + }) + it('loads the dev server URL with the requested view when in dev', () => { isMock.dev = true vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL) @@ -249,6 +255,16 @@ describe('createOrFocusDashboardPopout', () => { expect(instances[0].focus).toHaveBeenCalledTimes(1) }) + it('switches an existing popout to an explicitly requested view', () => { + const store = makeStore() + createOrFocusDashboardPopout(store as never) + const win = instances[0] + + createOrFocusDashboardPopout(store as never, 'map') + + expect(win.webContents.send).toHaveBeenCalledWith('dashboard:viewRequested', 'map') + }) + it('trusts only the live popout webContents', () => { const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 32fb00008ff..d23742f375a 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -18,7 +18,7 @@ const MIN_WIDTH = 480 const MIN_HEIGHT = 360 const DEFAULT_WIDTH = 960 const DEFAULT_HEIGHT = 720 -const DEFAULT_VIEW = 'kanban' +const DEFAULT_VIEW = 'board' const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout' // Why: singleton — the dashboard is a companion surface, so a second "Pop Out" @@ -138,7 +138,7 @@ function resolveRestoredBounds(store: Store | null): { */ export function createOrFocusDashboardPopout( store: Store | null, - view: string = DEFAULT_VIEW, + view?: string, options: { getKeybindings?: () => KeybindingOverrides | undefined } = {} ): BrowserWindow { if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { @@ -146,9 +146,14 @@ export function createOrFocusDashboardPopout( dashboardPopoutWindow.restore() } dashboardPopoutWindow.focus() + if (view) { + dashboardPopoutWindow.webContents.send('dashboard:viewRequested', view) + } return dashboardPopoutWindow } + const initialView = view ?? DEFAULT_VIEW + const savedBounds = resolveRestoredBounds(store) const window = new BrowserWindow({ @@ -283,7 +288,7 @@ export function createOrFocusDashboardPopout( broadcastPopoutOpenChanged(false) }) - loadDashboardPopout(window, view) + loadDashboardPopout(window, initialView) return window } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 53bdb0e7349..69a6f104b22 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,7 +10,12 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2497,17 +2502,22 @@ export type PreloadApi = { ) => Promise } dashboard: { - openPopout: () => Promise + openPopout: (view?: 'board' | 'map') => Promise publishSnapshot: (snapshot: DashboardSnapshot) => Promise getPopoutOpen: () => Promise onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise + sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise } terminalPreview: { connect: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index cf602b3f1cc..74ccf8b3876 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,12 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2280,7 +2285,8 @@ const api = { dashboard: { // Open the pop-out dashboard window, or focus it if already open. - openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => @@ -2308,6 +2314,20 @@ const api = { ipcRenderer.on('ui:ackDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + args: DashboardSleepWorkspaceArgs + ): void => callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, // ── Consumer side (pop-out window) ─────────────────────────────────── requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), @@ -2317,10 +2337,20 @@ const api = { ipcRenderer.on('dashboard:snapshot', listener) return () => ipcRenderer.removeListener('dashboard:snapshot', listener) }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) }, terminalPreview: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..8319d686597 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -92,7 +92,6 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' -import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -389,6 +388,7 @@ const FloatingTerminalPanel = lazy(() => ) // Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) +const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge')) // Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) @@ -757,8 +757,6 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() - useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) - useEffect(() => { return onOnboardingReopened(setOnboarding) }, []) @@ -2249,6 +2247,11 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + {settings?.experimentalAgentDashboardPopout === true ? ( + + + + ) : null} {/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */} string + showAgentlessWorkspaces: boolean onProjectToggle: (id: string) => void onStatusToggle: (id: string) => void onReviewToggle: (id: DashboardReviewFilter) => void + onAgentlessWorkspacesToggle: () => void onClear: () => void }): React.JSX.Element { return ( @@ -74,6 +78,15 @@ export function AgentDashboardFilterChips({ onRemove={() => onReviewToggle(id)} /> ))} + {showAgentlessWorkspaces ? ( + + ) : null} - ) : null} + ) : ( + + )}
{query || activeCount > 0 ? ( @@ -215,6 +274,47 @@ export function AgentDashboardToolbar({ + {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + + + ) : null} + {agentStates && agentStateCounts ? ( + <> + + {translate('dashboardPopout.map.filters.showStates', 'Agent states')} + + {AGENT_STATE_ROWS.map(({ state, dotState }) => ( + onAgentStateToggle?.(state)} + onSelect={(event) => event.preventDefault()} + > + + {agentStateLabel(state)} + + + ))} + + + ) : null} {translate('dashboardPopout.filters.project', 'Project')} @@ -285,9 +385,11 @@ export function AgentDashboardToolbar({ projects={projects} statuses={statuses} reviewLabel={reviewLabel} + showAgentlessWorkspaces={showAgentlessWorkspaces === true} onProjectToggle={toggleProject} onStatusToggle={toggleStatus} onReviewToggle={toggleReview} + onAgentlessWorkspacesToggle={() => onShowAgentlessWorkspacesChange?.(false)} onClear={clearFilters} /> ) : null} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index f9390570e30..58bb890d139 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -3,15 +3,20 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import type { DashboardCard, DashboardFilterOptions, - DashboardSnapshot + DashboardSnapshot, + DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' import { AgentKanbanBoard } from './AgentKanbanBoard' +const MAP_LOAD_TIMEOUT = { timeout: 5_000 } + // Stub the card and dialog so the board test stays free of xterm / Radix // machinery while still exercising the board-owned dialog wiring. vi.mock('./AgentKanbanCard', () => ({ @@ -54,6 +59,17 @@ vi.mock('./AgentTerminalDialog', () => ({ >
+ ), + AgentTerminalPanel: ({ + card, + onOpenChange + }: { + card: DashboardCard | null + onOpenChange: (open: boolean) => void + }) => ( +
+
) })) @@ -79,12 +95,26 @@ function card(overrides: Partial): DashboardCard { } } +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo', + worktreeName: 'wt', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + function renderBoard( cards: DashboardCard[], options: { showIdle?: boolean repoIconsByRepoId?: Record filterOptions?: DashboardFilterOptions + workspaces?: DashboardWorkspace[] } = {} ): void { const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options } @@ -96,6 +126,11 @@ const ackAgent = vi.fn(async () => {}) describe('AgentKanbanBoard', () => { beforeEach(async () => { await i18n.changeLanguage('en') + localStorage.clear() + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) // The board relays seen-acks through the dashboard preload API. ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } }) @@ -104,6 +139,7 @@ describe('AgentKanbanBoard', () => { vi.useRealTimers() vi.clearAllMocks() vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('renders the three default columns in order', () => { @@ -112,6 +148,160 @@ describe('AgentKanbanBoard', () => { expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done']) }) + it('loads the map as a recoverable dynamic chunk', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx'), + 'utf8' + ) + + expect(source).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'") + expect(source).toMatch(/import\('\.\/AgentDashboardMapView'\)/) + expect(source).not.toMatch(/from\s+['"]\.\/(?:AgentMap|useAgentMap|agent-map-)/) + }) + + it('keeps the dashboard and map available as separate views', async () => { + renderBoard([]) + + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect( + await screen.findByText('0 of 0 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + expect(screen.queryByText('Live containment map')).not.toBeInTheDocument() + // The map has no rail of its own; its filters live in the shared toolbar. + expect(screen.queryByRole('complementary', { name: 'Map filters' })).not.toBeInTheDocument() + expect(screen.queryByText('Agent states')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) + expect(screen.getByText('Needs You')).toBeInTheDocument() + }) + + it('filters the map from the shared toolbar filter, not a rail', async () => { + renderBoard([ + card({ paneKey: 'busy', worktreeName: 'busy-wt', worktreeId: 'w-busy' }), + card({ + paneKey: 'finished', + worktreeName: 'done-wt', + worktreeId: 'w-done', + bucket: 'done', + dotState: 'done', + finishedAt: 5, + unseen: true + }) + ]) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect( + await screen.findByText('2 of 2 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /Working/ })) + + expect(await screen.findByText('1 of 2 agents shown')).toBeInTheDocument() + // A muted state counts toward the Filter badge like any other filter. The + // open menu hides the trigger from the a11y tree, so read it by its label. + expect(screen.getByRole('menu')).toHaveAccessibleName('Filter 1') + }) + + it('offers agent states only on the map, where no column separates them', async () => { + renderBoard([card({ paneKey: 'busy' })]) + + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + expect(await screen.findByText('Project')).toBeInTheDocument() + expect(screen.queryByText('Agent states')).not.toBeInTheDocument() + + fireEvent.keyDown(document.body, { key: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + + expect(await screen.findByText('Agent states')).toBeInTheDocument() + }) + + it('toggles workspaces without agents from the shared map filter', async () => { + renderBoard([card({ paneKey: 'busy' })], { + workspaces: [workspace(), workspace({ worktreeId: 'empty', worktreeName: 'Empty child' })] + }) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + const workspaceToggle = await screen.findByRole('menuitemcheckbox', { + name: /Workspaces without agents/ + }) + fireEvent.click(workspaceToggle) + + expect(workspaceToggle).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(document.body, { key: 'Escape' }) + + expect( + await screen.findByRole( + 'button', + { name: 'Open Empty child worktree details' }, + MAP_LOAD_TIMEOUT + ) + ).toBeInTheDocument() + expect(screen.getByText('Workspaces without agents')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + }) + + it('keeps the selected map visible beside its terminal panel', async () => { + const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) + + expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument() + const terminalPanel = screen.getByTestId('terminal-panel') + expect(terminalPanel).toHaveAttribute('data-pty-id', 'p1') + expect(terminalPanel.parentElement).toHaveClass('flex-row-reverse') + expect( + screen.getByLabelText('Nested project, workspace, and agent map').closest('section') + ).toHaveClass('w-1/2', 'flex-none') + expect(screen.getByRole('button', { name: /Map agent/ })).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Map agent/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByText('200%')).toBeInTheDocument() + expect(screen.queryByText('Map filters')).not.toBeInTheDocument() + expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument() + }) + + it('closes the adjacent terminal instead of turning it into a board dialog', async () => { + const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) + + expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('terminal-dialog')).toHaveAttribute('data-open', 'false') + }) + + it('focuses search with Ctrl+K without taking focus from response fields', () => { + renderBoard([]) + const search = screen.getByLabelText('Search agents') + + fireEvent.keyDown(document.body, { key: 'k', ctrlKey: true }) + expect(search).toHaveFocus() + + const response = document.createElement('textarea') + document.body.append(response) + response.focus() + fireEvent.keyDown(response, { key: 'k', ctrlKey: true }) + expect(response).toHaveFocus() + response.remove() + }) + it('places cards in their bucket column and counts them', () => { renderBoard([ card({ bucket: 'attention', worktreeName: 'a1' }), @@ -252,7 +442,9 @@ describe('AgentKanbanBoard', () => { it('keeps the terminal dialog open across bucket moves and card removal', () => { const agent = card({ paneKey: 'pk-1', bucket: 'done', worktreeName: 'wt1' }) - const { rerender } = render() + const { rerender } = render( + + ) expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false') fireEvent.click(screen.getByTestId('card')) @@ -284,9 +476,16 @@ describe('AgentKanbanBoard', () => { // The ack round-trips through the main window; the next snapshot mutes it. rerender( - + ) expect(screen.getByTestId('card').dataset.unseen).toBe('false') + expect(screen.getByTestId('card').dataset.bucket).toBe('idle') expect(ackAgent).not.toHaveBeenCalled() // A state change while the dialog is open re-acks (watching counts as @@ -295,10 +494,55 @@ describe('AgentKanbanBoard', () => { ) expect(ackAgent).toHaveBeenCalledWith('pk-ack') }) + + it('keeps an acknowledged result visible as Idle in the map without review state', async () => { + const fresh = card({ + paneKey: 'fresh-result', + bucket: 'done', + dotState: 'done', + conversationName: 'Fresh result', + finishedAt: 900, + unseen: true + }) + const view = render( + + ) + + expect( + await screen.findByRole('button', { name: /Fresh result/ }, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Fresh result/ })) + expect(ackAgent).toHaveBeenCalledWith('fresh-result') + + view.rerender( + + ) + expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle') + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + + view.unmount() + render( + + ) + expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle') + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index ded2be0c221..376f38a1755 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { XIcon } from 'lucide-react' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Columns3, Orbit, XIcon } from 'lucide-react' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, type DashboardCard, - type DashboardSnapshot + type DashboardSleepWorkspaceArgs, + type DashboardSnapshot, + type DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { cn } from '@/lib/utils' @@ -20,6 +22,16 @@ import { } from './agent-board-filtering' import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { lazyWithRetry } from '@/lib/lazy-with-retry' + +export type AgentDashboardView = 'map' | 'board' + +const AgentDashboardMapView = lazyWithRetry( + () => + import('./AgentDashboardMapView').then((module) => ({ default: module.AgentDashboardMapView })), + { reloadKey: 'agent-dashboard-map-view' } +) /** Ack an agent in the pop-out window: relayed over IPC to the main renderer. * ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot, @@ -35,6 +47,18 @@ function revealAgentViaPopoutRelay(args: AgentRevealArgs): void { void window.api.dashboard.revealAgent?.(args) } +/** Start an agent from the pop-out window: the main renderer owns the store and + * the tab path, so the launch is relayed. Same `?.` HMR-skew guard. */ +function spawnAgentViaPopoutRelay(args: DashboardSpawnAgentArgs): void { + void window.api.dashboard.spawnAgent?.(args) +} + +/** Sleep a workspace from the pop-out window: the main renderer runs the + * teardown, which has to happen where the terminal panes live. */ +function sleepWorkspaceViaPopoutRelay(args: DashboardSleepWorkspaceArgs): void { + void window.api.dashboard.sleepWorkspace?.(args) +} + function bucketLabel(bucket: DashboardBucket): string { switch (bucket) { case 'attention': @@ -114,6 +138,7 @@ function KanbanColumn({ type AgentKanbanBoardProps = { snapshot: DashboardSnapshot + initialView?: AgentDashboardView /** Sizing for the outermost container. The pop-out fills the window * (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */ containerClassName?: string @@ -123,12 +148,24 @@ type AgentKanbanBoardProps = { /** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window * host activates the worktree/pane locally and closes the overlay. */ onRevealAgent?: (args: AgentRevealArgs) => void + /** Starts a new agent in a workspace. Defaults to the pop-out IPC relay; the + * in-window host launches through its own store. */ + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + /** Puts a workspace to sleep. Defaults to the pop-out IPC relay; the in-window + * host already offers the full sidebar menu, so it opts out. */ + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void /** When provided, renders a close control in the header (in-window mode). The * pop-out relies on its native window controls, so it omits this. */ onClose?: () => void /** Header controls rendered before the close button. The in-window host * passes its settings menu; the pop-out renderer has no store to drive it. */ headerActions?: React.ReactNode + /** Opens the map outside this renderer. The in-window drawer uses this to + * hand map work to the dedicated pop-out window. */ + onOpenMap?: () => void + /** The shared sidebar workspace menu is available only in the main renderer. */ + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void } /** The agent board: status columns fed by a snapshot. Shared by the pop-out @@ -136,12 +173,19 @@ type AgentKanbanBoardProps = { * how ack/reveal are routed. */ export function AgentKanbanBoard({ snapshot, + initialView = 'board', containerClassName = 'h-screen w-screen', onAckAgent = ackAgentViaPopoutRelay, onRevealAgent = revealAgentViaPopoutRelay, + onSpawnAgent = spawnAgentViaPopoutRelay, + onSleepWorkspace = sleepWorkspaceViaPopoutRelay, onClose, - headerActions + headerActions, + onOpenMap, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange }: AgentKanbanBoardProps): React.JSX.Element { + const [view, setView] = useState(initialView) const visibleBuckets = useMemo( () => DASHBOARD_BUCKET_ORDER.filter((bucket) => bucket !== 'idle' || snapshot.showIdle === true), @@ -151,11 +195,13 @@ export function AgentKanbanBoard({ () => snapshot.cards.filter((card) => visibleBuckets.includes(card.bucket)), [snapshot.cards, visibleBuckets] ) + const availableCards = view === 'map' ? snapshot.cards : visibleCards const [query, setQuery] = useState('') + const searchInputRef = useRef(null) const [filters, setFilters] = useState(EMPTY_DASHBOARD_FILTERS) const filteredCards = useMemo( - () => filterDashboardCards(visibleCards, query, filters), - [filters, query, visibleCards] + () => filterDashboardCards(availableCards, query, filters), + [availableCards, filters, query] ) const grouped = useMemo(() => groupByBucket(filteredCards), [filteredCards]) const hasRelativeTimestamps = useMemo( @@ -163,7 +209,6 @@ export function AgentKanbanBoard({ [snapshot.cards] ) const [now, setNow] = useState(() => Date.now()) - useEffect(() => { if (!hasRelativeTimestamps) { return @@ -174,6 +219,27 @@ export function AgentKanbanBoard({ }) }, [hasRelativeTimestamps]) + useEffect(() => { + const handleSearchShortcut = (event: KeyboardEvent): void => { + const usesPlatformModifier = navigator.userAgent.includes('Mac') + ? event.metaKey + : event.ctrlKey + if (!usesPlatformModifier || event.key.toLowerCase() !== 'k') { + return + } + if ( + event.target instanceof Element && + event.target.closest('input, textarea, [contenteditable="true"], .xterm') + ) { + return + } + event.preventDefault() + searchInputRef.current?.focus() + } + document.addEventListener('keydown', handleSearchShortcut) + return () => document.removeEventListener('keydown', handleSearchShortcut) + }, []) + // The open terminal dialog survives bucket moves: only the paneKey is // remembered, and the card data is re-resolved from each fresh snapshot. // The opened card is kept as a fallback so the dialog also survives the @@ -197,6 +263,20 @@ export function AgentKanbanBoard({ setOpenedCard(null) } }, []) + const handleViewChange = useCallback( + (nextView: AgentDashboardView) => { + if (nextView === 'map' && onOpenMap) { + onOpenMap() + return + } + if (nextView === view) { + return + } + setOpenedCard(null) + setView(nextView) + }, + [onOpenMap, view] + ) // Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute // rows): opening a dialog acks the agent, and the next snapshot comes back @@ -214,23 +294,53 @@ export function AgentKanbanBoard({ if (dialogCard?.unseen) { onAckAgent(dialogCard.paneKey) } - }, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent]) + }, [dialogCard?.paneKey, dialogCard?.unseen, onAckAgent]) return ( // Why: the pop-out is its own React root with no app-level provider, and the // card's repo tooltip needs one in both hosts. Nesting inside the main // window's provider is harmless. -
+

{translate('dashboardPopout.title', 'Agents')}

{translate('dashboardPopout.total', '{{count}} total', { - count: visibleCards.length + count: availableCards.length })} +
+ + +
{headerActions || onClose ? (
{headerActions} @@ -247,35 +357,63 @@ export function AgentKanbanBoard({
) : null}
- -
- {/* Auto margins center the capped board and collapse during horizontal overflow. */} -
- {visibleBuckets.map((bucket) => ( - - ))} -
-
- + {view !== 'board' ? ( + + + + ) : ( + <> + +
+ {/* Auto margins center the capped board and collapse during horizontal overflow. */} +
+ {visibleBuckets.map((bucket) => ( + + ))} +
+
+ + )} + {view === 'board' ? ( + + ) : null}
) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 24394cae866..5036478d94c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -159,7 +159,7 @@ describe('AgentKanbanCard', () => { expect(screen.queryByRole('img', { name: 'In review' })).not.toBeInTheDocument() }) - it('tints attention amber and done green, leaving every other state neutral', () => { + it('tints unseen Done green and keeps acknowledged Done neutral as Idle', () => { const { container: attention } = renderCard({ card: card({ bucket: 'attention', dotState: 'waiting' }), now: 2_000 @@ -168,14 +168,14 @@ describe('AgentKanbanCard', () => { cleanup() const { container: done } = renderCard({ - card: card({ bucket: 'idle', dotState: 'done' }), + card: card({ bucket: 'done', dotState: 'done', unseen: true }), now: 2_000 }) expect(done.firstElementChild?.className).toContain('border-emerald-500/40') cleanup() const { container: idle } = renderCard({ - card: card({ bucket: 'idle', dotState: 'idle' }), + card: card({ bucket: 'idle', dotState: 'done', unseen: false }), now: 2_000 }) const idleClassName = idle.firstElementChild?.className ?? '' diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 556d5f563c1..4e6cffa14af 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -14,7 +14,10 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard +} from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' @@ -200,7 +203,8 @@ export const AgentKanbanCard = memo( // "answer me", green for "finished, look at it". Everything else stays // neutral so the tint keeps meaning something. const needsYou = card.bucket === 'attention' - const isDone = card.dotState === 'done' + const displayState = dashboardCardDisplayState(card) + const isDone = displayState === 'done' // Why: the session's own name heads the card. Without one the worktree is // the best heading left — and then the footer drops it rather than say it // twice. @@ -240,7 +244,7 @@ export const AgentKanbanCard = memo( > {heading} - {card.askSummary ? null : } + {card.askSummary ? null : }
{card.lastUserMessage || card.lastAgentMessage ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx new file mode 100644 index 00000000000..483e9d1e9ed --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -0,0 +1,752 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' +import type { AgentMapState } from './agent-map-filter' +import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void + } = {} +): ReturnType { + return render( + + ) +} + +describe('AgentMap', () => { + const originalUserAgent = navigator.userAgent + let boundsSpy: ReturnType + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) + } + } + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}) + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + it('renders the amber marker only for unread agents', () => { + const finished = card({ + paneKey: 'done', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + renderMap([card(), finished]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + expect(workingNode).toHaveClass('fleet-status-working') + expect(doneNode).toHaveClass('fleet-status-done') + expect(workingNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + expect(workingNode.querySelector('[data-agent-unread-marker]')).not.toBeInTheDocument() + expect(doneNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + const unreadMarker = doneNode.querySelector('[data-agent-unread-marker]') + expect(unreadMarker).toHaveClass('agent-map-agent-unread-mark') + // On the ring circumference at the top-left diagonal, where the halo breaks the ring. + const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2) + expect(unreadMarker).toHaveAttribute('cx', onRing) + expect(unreadMarker).toHaveAttribute('cy', onRing) + expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 0.225 + ) + expect(unreadMarker).toHaveAttribute('vector-effect', 'none') + expect(doneNode).toHaveAccessibleName(/unread/) + }) + + it('lets attention override working on the worktree glow', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Finished agent', + worktreeId: 'worktree-done', + worktreeName: 'Finished worktree', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + const waiting = card({ + paneKey: 'waiting', + conversationName: 'Question agent', + bucket: 'attention', + dotState: 'waiting' + }) + const { container } = renderMap([card(), waiting, done]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const waitingNode = screen.getByRole('button', { name: /Question agent/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + const workingRing = screen.getByRole('button', { + name: /Open Agent map worktree details/ + }) + const doneRing = screen.getByRole('button', { + name: /Open Finished worktree worktree details/ + }) + + expect(workingNode.querySelector('[data-agent-map-agent-working-glow]')).toBeInTheDocument() + expect(waitingNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(doneNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(workingRing).toHaveClass('is-waiting') + expect(workingRing).not.toHaveClass('is-working') + expect(doneRing).not.toHaveClass('is-working') + expect(container.querySelector('[data-agent-map-worktree-status-glow]')).toHaveAttribute( + 'data-worktree-active-status', + 'waiting' + ) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + }) + + it('keeps glow markup bounded for a large visible worktree', () => { + const cards = Array.from({ length: 120 }, (_, index) => { + const working = index % 2 === 0 + return card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + conversationName: `Agent ${index}`, + bucket: working ? 'working' : 'done', + dotState: working ? 'working' : 'done', + finishedAt: working ? null : NOW - 60_000 + }) + }) + const { container } = renderMap(cards, { selectedPaneKey: 'pane-0' }) + + expect(container.querySelectorAll('[data-agent-map-agent-working-glow]')).toHaveLength(60) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + expect(container.querySelectorAll('filter')).toHaveLength(0) + }) + + it('enlarges unread markers when the full fleet is zoomed out', () => { + const fleet = Array.from({ length: 72 }, (_, index) => + card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + worktreeId: `worktree-${index}`, + worktreeName: `Worktree ${index}`, + conversationName: `Agent ${index}`, + unseen: index === 0 + }) + ) + const { container } = renderMap(fleet) + const marker = container.querySelector('[data-agent-unread-marker]')! + const agentMark = + marker.parentElement!.querySelector('.agent-map-agent-mark')! + + expect(Number(marker.getAttribute('r'))).toBeGreaterThan( + Number(agentMark.getAttribute('r')) * 0.225 + ) + expect(marker).toHaveAttribute('vector-effect', 'none') + }) + + it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => { + const mapScale = 0.2 + const markerScale = agentMapAttentionMarkerScale(mapScale) + + expect(markerScale).toBeGreaterThan(1) + expect(markerScale).toBeLessThan(1 / mapScale) + expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25) + }) + + it('shows worktree details and opens a running agent', () => { + const onOpenTerminal = vi.fn() + const running = card() + renderMap([running], { onOpenTerminal }) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + fireEvent.click(ring) + + expect(ring).toHaveClass('is-open') + expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument() + // The popover names the project; the map itself draws it uppercased. + expect(screen.getByText('Orca')).toBeInTheDocument() + expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument() + expect(screen.getByText('Agent alpha')).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1]) + expect(onOpenTerminal).toHaveBeenCalledWith(running) + expect(ring).not.toHaveClass('is-open') + }) + + it('starts a new agent from the worktree details picker', () => { + const onSpawnAgent = vi.fn() + renderMap([card()], { + onSpawnAgent, + launchableAgentsByWorktreeId: { 'worktree-1': ['claude', 'codex'] } + }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Codex/ })) + + // The raw worktree id, not the host-qualified map identity. + expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' }) + }) + + it('explains an empty picker rather than offering nothing', () => { + renderMap([card()], { onSpawnAgent: vi.fn() }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('No enabled agents detected.')).toBeInTheDocument() + }) + + it('offers sleep and launch on right-click where the store menu is unavailable', async () => { + const onSleepWorkspace = vi.fn() + renderMap([card()], { + onSleepWorkspace, + onSpawnAgent: vi.fn(), + launchableAgentsByWorktreeId: { 'worktree-1': ['claude'] } + }) + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 10, + clientY: 10 + }) + + expect(await screen.findByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByText('Sleep')) + expect(onSleepWorkspace).toHaveBeenCalledWith({ worktreeId: 'worktree-1' }) + }) + + it('toggles worktree details from the keyboard', () => { + renderMap([card()]) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).toHaveClass('is-open') + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).not.toHaveClass('is-open') + }) + + it('labels folder workspaces without presenting them as worktrees', () => { + renderMap([card({ workspaceKind: 'folder', worktreeName: 'Documentation' })]) + + expect( + screen.getByRole('button', { name: 'Open Documentation folder workspace details' }) + ).toBeInTheDocument() + }) + + it('connects spawned workers beneath their visible parent', () => { + const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' }) + const child = card({ + paneKey: 'child', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const orphan = card({ + paneKey: 'orphan', + parentPaneKey: 'filtered-parent', + conversationName: 'Orphaned worker' + }) + const { container } = renderMap([parent, child, nested, orphan]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) + expect(workerLink).toHaveClass('agent-map-lineage-link') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + expect(subagentLink).toHaveAttribute('data-parent-pane-key', 'child') + }) + + it('connects spawned workers across worktree rings', () => { + const parent = card({ + paneKey: 'parent', + worktreeId: 'parent-worktree', + worktreeName: 'Parent workspace', + conversationName: 'Coordinator' + }) + const child = card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child workspace', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + worktreeId: 'nested-worktree', + worktreeName: 'Nested workspace', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const { container } = renderMap([parent, child, nested]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + }) + + it('keeps lineage styling to one lightweight path per relationship at fleet scale', () => { + const cards = Array.from({ length: 240 }, (_, index) => + card({ + paneKey: `agent-${index}`, + parentPaneKey: index === 0 ? undefined : `agent-${index - 1}`, + conversationName: `Agent ${index}` + }) + ) + const { container } = renderMap(cards, { selectedPaneKey: 'agent-0' }) + + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239) + expect( + container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]') + ).toHaveLength(1) + expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength( + 238 + ) + expect(container.querySelectorAll('filter, animate, animateTransform')).toHaveLength(0) + }) + + it('connects visible child worktrees beneath their parent ring', () => { + const { container } = renderMap([ + card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'orphan', + worktreeId: 'orphan-worktree', + worktreeName: 'Orphan', + parentWorktreeId: 'filtered-parent' + }) + ]) + const links = container.querySelectorAll('[data-agent-map-worktree-lineage-link]') + + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('data-parent-worktree-id', 'parent-worktree') + expect(links[0]).toHaveAttribute('data-child-worktree-id', 'child-worktree') + }) + + it('opens the shared dashboard terminal dialog when an agent is clicked', () => { + const onOpenTerminal = vi.fn() + const agent = card() + renderMap([agent], { onOpenTerminal }) + + fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ })) + expect(onOpenTerminal).toHaveBeenCalledWith(agent) + }) + + it('keeps a selected node visible while compacting around an adjacent terminal', () => { + const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true }) + + const selectedNode = screen.getByRole('button', { name: /Agent alpha/ }) + expect(selectedNode).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + const label = container.querySelector('.agent-map-worktree-label-group')! + expect(selectedNode.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + expect(label.querySelector('rect')).not.toBeInTheDocument() + expect(screen.getByText('270%')).toBeInTheDocument() + expect(screen.queryByText('Map filters')).not.toBeInTheDocument() + expect(screen.queryByRole('group', { name: 'Host filter' })).not.toBeInTheDocument() + }) + + it('eases the viewport into the selected agent', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const agent = card() + const view = renderMap([agent]) + + view.rerender( + + ) + act(() => frames.shift()?.(0)) + expect(screen.getByText('100%')).toBeInTheDocument() + act(() => frames.shift()?.(120)) + expect(screen.getByText('249%')).toBeInTheDocument() + act(() => frames.shift()?.(240)) + expect(screen.getByText('270%')).toBeInTheDocument() + }) + + it('keeps the selected node centered when topology changes around it', () => { + const selected = card() + const view = renderMap([selected], { selectedPaneKey: selected.paneKey }) + const svg = view.container.querySelector('.agent-map-canvas > svg')! + const selectedNode = (): SVGGElement => + view.container.querySelector('.agent-map-agent-node.is-selected')! + const nodeCenter = (): [number, number] => { + const match = selectedNode() + .getAttribute('transform') + ?.match(/translate\(([^ ]+) ([^)]+)\)/) + return [Number(match?.[1]), Number(match?.[2])] + } + const viewportCenter = (): [number, number] => { + const [x, y, width, height] = svg.getAttribute('viewBox')!.split(' ').map(Number) + return [x + width / 2, y + height / 2] + } + + const originalNodeCenter = nodeCenter() + view.rerender( + + ) + + expect(nodeCenter()).not.toEqual(originalNodeCenter) + expect(viewportCenter()[0]).toBeCloseTo(nodeCenter()[0]) + expect(viewportCenter()[1]).toBeCloseTo(nodeCenter()[1]) + }) + + it('increases map label scale when users zoom out', () => { + const { container } = renderMap([card()]) + const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement + const initialScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + + const readsBeforeZoom = boundsSpy.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + + const zoomedScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(zoomedScale).toBeGreaterThan(initialScale) + }) + + it('avoids idle pointer layout reads and batches active viewport updates by frame', () => { + const frames: FrameRequestCallback[] = [] + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('requestAnimationFrame', requestFrame) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const { container } = renderMap([card()]) + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + + const readsBeforeHover = boundsSpy.mock.calls.length + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).toHaveBeenCalledWith(1) + const readsAfterPointerDown = boundsSpy.mock.calls.length + const initialViewBox = svg.getAttribute('viewBox') + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + + expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(requestFrame).toHaveBeenCalledOnce() + expect(svg).toHaveAttribute('viewBox', initialViewBox) + + act(() => frames.shift()?.(0)) + expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) + + requestFrame.mockClear() + const readsBeforeWheel = boundsSpy.mock.calls.length + const wheel = (): void => { + const event = new Event('wheel', { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + deltaY: { value: -10 }, + clientX: { value: 100 }, + clientY: { value: 100 } + }) + fireEvent(svg, event) + expect(event.defaultPrevented).toBe(true) + } + wheel() + wheel() + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(requestFrame).toHaveBeenCalledOnce() + }) + + it('keeps active labels visible and progressively discloses quiet labels', () => { + const quiet = card({ + paneKey: 'done', + worktreeId: 'quiet-worktree', + worktreeName: 'Quiet result', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const { container } = renderMap([card(), quiet]) + const labels = [...container.querySelectorAll('.agent-map-worktree-label')] + const activeGroup = labels.find((label) => label.textContent === 'Agent map')?.parentElement + const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement + + expect(activeGroup).toHaveClass('is-visible') + expect(quietGroup).not.toHaveClass('is-visible') + }) + + it('shows unseen completions as Done and acknowledged completions as Idle by default', () => { + const seenResult = card({ + paneKey: 'seen', + conversationName: 'Seen result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const newResult = card({ + paneKey: 'new', + conversationName: 'New result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 2 * 60_000, + unseen: true + }) + renderMap([card(), seenResult, newResult]) + + expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done') + expect(screen.getByRole('button', { name: /Seen result/ })).toHaveClass('fleet-status-idle') + }) + + it('hides the states the toolbar filter has muted', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Done agent', + unseen: true, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + renderMap([card(), done], { enabledStates: new Set(['done']) }) + + expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Done agent/ })).toBeInTheDocument() + }) + + it('preserves the viewport when filters temporarily empty the map', () => { + const agent = card() + const all = new Set(['attention', 'working', 'done', 'idle']) + const view = renderMap([agent], { enabledStates: all }) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + const onOpenTerminal = vi.fn() + view.rerender( + (['done'])} + /> + ) + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender( + + ) + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('preserves the viewport through an empty source snapshot', () => { + const agent = card() + const view = renderMap([agent]) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + view.rerender() + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender() + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('aggregates only acknowledged idle results without repacking topology', () => { + const results = Array.from({ length: 5 }, (_, index) => + card({ + paneKey: `done-${index}`, + conversationName: `Result ${index}`, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + ) + const view = renderMap(results) + const { container } = view + + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(1) + + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + selectedPaneKey="done-0" + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx new file mode 100644 index 00000000000..4e9c3c413ca --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas' +import { + filterAgentMapCards, + type AgentMapState, + type AgentMapHostFilter +} from './agent-map-filter' +import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout' +import './agent-map.css' + +type AgentMapProps = { + cards: DashboardCard[] + workspaces?: DashboardWorkspace[] + repoIconsByRepoId?: Record + now: number + className?: string + compact?: boolean + selectedPaneKey?: string | null + /** Owned by the board so the shared toolbar filter can drive it. Defaults to + * every state, i.e. the map shows whatever it is handed. */ + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onOpenTerminal: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] +const ALL_AGENT_STATES: ReadonlySet = new Set([ + 'attention', + 'working', + 'done', + 'idle' +]) +const EMPTY_WORKSPACES: DashboardWorkspace[] = [] + +function hostFilterLabel(filter: AgentMapHostFilter): string { + switch (filter) { + case 'all': + return translate('dashboardPopout.map.host.all', 'All hosts') + case 'local': + return translate('dashboardPopout.map.host.local', 'Local') + case 'ssh': + return translate('dashboardPopout.map.host.ssh', 'SSH') + case 'wsl': + return translate('dashboardPopout.map.host.wsl', 'WSL') + case 'remote': + return translate('dashboardPopout.map.host.remote', 'Remote') + } +} + +export function AgentMap({ + cards, + workspaces = EMPTY_WORKSPACES, + repoIconsByRepoId, + now, + className, + compact = false, + selectedPaneKey = null, + enabledStates = ALL_AGENT_STATES, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onOpenTerminal, + onSpawnAgent, + onSleepWorkspace +}: AgentMapProps): React.JSX.Element { + const canvasRef = useRef(null) + const layoutCacheRef = useRef(null) + const [hostFilter, setHostFilter] = useState('all') + const hostCounts = useMemo(() => { + const counts: Record = { + local: 0, + ssh: 0, + wsl: 0, + remote: 0 + } + for (const card of cards) { + counts[card.hostKind ?? 'local'] += 1 + } + for (const workspace of workspaces) { + counts[workspace.hostKind] += 1 + } + return counts + }, [cards, workspaces]) + const visibleCards = useMemo( + () => + filterAgentMapCards({ + cards, + enabledStates, + hostFilter + }), + [cards, enabledStates, hostFilter] + ) + const visibleWorkspaces = useMemo( + () => + hostFilter === 'all' + ? workspaces + : workspaces.filter((workspace) => workspace.hostKind === hostFilter), + [hostFilter, workspaces] + ) + const layoutResult = useMemo( + () => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces), + [visibleCards, visibleWorkspaces, now] + ) + useEffect(() => { + layoutCacheRef.current = layoutResult.cache + }, [layoutResult.cache]) + const layout = layoutResult.layout + + return ( +
+
+
+ + {translate( + 'dashboardPopout.map.filters.canvasSummary', + '{{shown}} of {{total}} agents shown', + { + shown: visibleCards.length, + total: cards.length + } + )} + + {!compact ? ( +
+ {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( + (option) => ( + + ) + )} +
+ ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx new file mode 100644 index 00000000000..7ec41a3d8b4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +const ringRender = vi.hoisted(() => vi.fn()) +vi.mock('./AgentMapWorktreeRingNode', () => ({ + AgentMapWorktreeRingNode: ({ worktree }: { worktree: { id: string } }) => { + ringRender(worktree.id) + return + } +})) + +import { AgentMap } from './AgentMap' + +const NOW = 2_000_000_000 +const CARD: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Measure map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Performance', + startedAt: NOW - 1_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false +} + +describe('AgentMapCanvas pointer performance', () => { + const frames: FrameRequestCallback[] = [] + + beforeEach(() => { + frames.length = 0 + ringRender.mockClear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 560, + width: 800, + height: 560, + toJSON: () => ({}) + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('presents the map as a non-selectable panning surface', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + + expect(svg).toHaveClass('cursor-grab', 'touch-none', 'select-none', 'active:cursor-grabbing') + }) + + it('coalesces drag frames without rerendering worktree nodes', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + expect(ringRender).toHaveBeenCalledTimes(1) + + fireEvent.pointerDown(svg, { pointerId: 2, button: 2, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).not.toHaveBeenCalled() + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 60, clientY: 20 }) + expect(frames).toHaveLength(1) + + act(() => frames.shift()?.(0)) + expect(ringRender).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx new file mode 100644 index 00000000000..33cd767cc6a --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -0,0 +1,415 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { + AGENT_MAP_AGENT_RADIUS, + type AgentMapAgentNode, + type AgentMapProjectRing, + type AgentMapLayout +} from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' +import { AgentMapViewportControls } from './AgentMapViewportControls' +import { + agentMapAgents, + navigableAgentMapAgents, + nextDirectionalAgent +} from './agent-map-navigation' +import type { AgentMapViewport } from './agent-map-viewport-transition' +import { useAgentMapContextMenus } from './useAgentMapContextMenus' +import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' +import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' + +const MIN_ZOOM = 0.7 +const MAX_ZOOM = 24 +const AGENT_FOCUS_DURATION_MS = 240 +const AGENT_FOCUS_RADIUS_PX = 24 + +type Point = { x: number; y: number } + +export type AgentMapCanvasHandle = { + fit: () => void + focusProject: (project: AgentMapProjectRing) => void +} + +type AgentMapCanvasProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)) +} + +function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} + +export const AgentMapCanvas = forwardRef( + function AgentMapCanvas( + { + layout, + repoIconsByRepoId, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onSelectAgent, + onSpawnAgent, + onSleepWorkspace + }, + forwardedRef + ): React.JSX.Element { + const containerRef = useRef(null) + const svgRef = useRef(null) + const nodeRefs = useRef(new Map()) + const dragRef = useRef<{ + pointerId: number + point: Point + center: Point + worldPerPixelX: number + worldPerPixelY: number + } | null>(null) + const viewportFrameRef = useRef(null) + const pendingViewportRef = useRef(null) + const interactionBoundsRef = useRef(null) + const hasShownProjectsRef = useRef(layout.projects.length > 0) + const clearInteractionBounds = useCallback(() => { + interactionBoundsRef.current = null + }, []) + const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds) + const [viewport, setViewport] = useState({ + center: { x: layout.width / 2, y: layout.height / 2 }, + zoom: 1 + }) + const prefersReducedMotion = usePrefersReducedMotion() + const viewportRef = useRef(viewport) + const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } = + useAgentMapContextMenus({ + enabled: workspaceContextMenusEnabled, + launchableAgentsByWorktreeId, + onOpenChange: onWorkspaceContextMenuOpenChange, + onSpawnAgent, + onSleepWorkspace + }) + const { center, zoom } = viewport + const agents = useMemo(() => agentMapAgents(layout), [layout]) + const navigableAgents = useMemo( + () => navigableAgentMapAgents(layout, zoom, allowAggregation, selectedPaneKey), + [allowAggregation, layout, selectedPaneKey, zoom] + ) + const hasProjects = layout.projects.length > 0 + const aspect = size.width / Math.max(1, size.height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + const baseHeight = baseWidth / aspect + const viewWidth = baseWidth / zoom + const viewHeight = baseHeight / zoom + const mapScale = size.width / viewWidth + const labelScale = Math.max(1, 1 / mapScale) + const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}` + const focusZoom = agentFocusZoom(layout, size.width, size.height) + const resolveFocusZoom = useCallback((): number => { + const bounds = containerRef.current?.getBoundingClientRect() + return bounds && bounds.width > 0 && bounds.height > 0 + ? agentFocusZoom(layout, bounds.width, bounds.height) + : focusZoom + }, [focusZoom, layout]) + + const commitViewport = useCallback((next: AgentMapViewport): void => { + viewportRef.current = next + pendingViewportRef.current = null + interactionBoundsRef.current = null + setViewport(next) + }, []) + const { animate: animateViewport, stop: stopViewportTransition } = + useAgentMapViewportTransition({ + durationMs: AGENT_FOCUS_DURATION_MS, + reducedMotion: prefersReducedMotion, + onFrame: commitViewport + }) + useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition + }) + const applyViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + commitViewport(next) + }, + [commitViewport, stopViewportTransition] + ) + const scheduleViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + viewportRef.current = next + pendingViewportRef.current = next + if (viewportFrameRef.current !== null) { + return + } + viewportFrameRef.current = requestAnimationFrame(() => { + viewportFrameRef.current = null + interactionBoundsRef.current = null + const pending = pendingViewportRef.current + pendingViewportRef.current = null + if (pending) { + setViewport(pending) + } + }) + }, + [stopViewportTransition] + ) + const fit = useCallback((): void => { + applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 }) + }, [applyViewport, layout.height, layout.width]) + const focusProject = useCallback( + (project: AgentMapProjectRing): void => { + const projectWidth = project.radius * 2.5 + const projectHeight = project.radius * 2.5 + applyViewport({ + center: { x: project.x, y: project.y }, + zoom: clamp( + Math.min(baseWidth / projectWidth, baseHeight / projectHeight), + MIN_ZOOM, + MAX_ZOOM + ) + }) + }, + [applyViewport, baseHeight, baseWidth] + ) + useImperativeHandle(forwardedRef, () => ({ fit, focusProject }), [fit, focusProject]) + + useEffect(() => { + if (hasProjects && !hasShownProjectsRef.current) { + hasShownProjectsRef.current = true + fit() + } + }, [fit, hasProjects]) + + useEffect( + () => () => { + if (viewportFrameRef.current !== null) { + cancelAnimationFrame(viewportFrameRef.current) + } + }, + [] + ) + + const handleAgentKeyDown = useCallback( + (event: React.KeyboardEvent, agent: AgentMapAgentNode): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelectAgent(agent.card) + return + } + const direction = + event.key === 'ArrowLeft' + ? { x: -1, y: 0 } + : event.key === 'ArrowRight' + ? { x: 1, y: 0 } + : event.key === 'ArrowUp' + ? { x: 0, y: -1 } + : event.key === 'ArrowDown' + ? { x: 0, y: 1 } + : null + if (!direction) { + return + } + event.preventDefault() + const next = nextDirectionalAgent(agent, navigableAgents, direction) + nodeRefs.current.get(next?.card.paneKey ?? '')?.focus() + }, + [navigableAgents, onSelectAgent] + ) + + const zoomAt = useCallback( + (nextZoom: number, clientX?: number, clientY?: number): void => { + const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) + if (clientX === undefined || clientY === undefined) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + const bounds = + interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + interactionBoundsRef.current = bounds + const current = viewportRef.current + const currentWidth = baseWidth / current.zoom + const currentHeight = baseHeight / current.zoom + const anchorX = + current.center.x - + currentWidth / 2 + + ((clientX - bounds.left) / bounds.width) * currentWidth + const anchorY = + current.center.y - + currentHeight / 2 + + ((clientY - bounds.top) / bounds.height) * currentHeight + const nextWidth = baseWidth / clampedZoom + const nextHeight = baseHeight / clampedZoom + const xRatio = (clientX - bounds.left) / bounds.width + const yRatio = (clientY - bounds.top) / bounds.height + scheduleViewport({ + center: { + x: anchorX - (xRatio - 0.5) * nextWidth, + y: anchorY - (yRatio - 0.5) * nextHeight + }, + zoom: clampedZoom + }) + }, + [applyViewport, baseHeight, baseWidth, scheduleViewport] + ) + + useEffect(() => { + if (!hasProjects) { + return + } + const svg = svgRef.current + if (!svg) { + return + } + const handleWheel = (event: WheelEvent): void => { + event.preventDefault() + zoomAt( + viewportRef.current.zoom * Math.exp(-event.deltaY * 0.0015), + event.clientX, + event.clientY + ) + } + svg.addEventListener('wheel', handleWheel, { passive: false }) + return () => svg.removeEventListener('wheel', handleWheel) + }, [hasProjects, zoomAt]) + + return ( +
+ {!hasProjects ? ( +
+ {translate('dashboardPopout.map.empty', 'No agents match the current filters.')} +
+ ) : ( + { + if (event.button !== 0) { + return + } + if ( + (event.target as Element).closest( + '[data-agent-map-agent], .agent-map-worktree-ring' + ) + ) { + return + } + const bounds = event.currentTarget.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return + } + const current = viewportRef.current + dragRef.current = { + pointerId: event.pointerId, + point: { x: event.clientX, y: event.clientY }, + center: current.center, + worldPerPixelX: baseWidth / current.zoom / bounds.width, + worldPerPixelY: baseHeight / current.zoom / bounds.height + } + event.currentTarget.setPointerCapture(event.pointerId) + }} + onPointerMove={(event) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) { + return + } + scheduleViewport({ + center: { + x: drag.center.x - (event.clientX - drag.point.x) * drag.worldPerPixelX, + y: drag.center.y - (event.clientY - drag.point.y) * drag.worldPerPixelY + }, + zoom: viewportRef.current.zoom + }) + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + } + }} + onPointerCancel={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + }} + > + + + )} + + zoomAt(viewportRef.current.zoom * 1.25)} + onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)} + /> + {contextMenus} +
+ ) + } +) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx new file mode 100644 index 00000000000..ba653e7cd59 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useRef } from 'react' +import { Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { useAppStore } from '@/store' +import { getRepoHeaderCreateState } from '@/components/sidebar/repo-header-create-state' +import { translate } from '@/i18n/i18n' + +const FOLDER_PROJECT_PREFIX = 'folder-workspace:' + +export type AgentMapProjectContextMenuRequest = { + id: number + projectId: string + clientX: number + clientY: number +} + +type AgentMapProjectContextMenuProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenu({ + request, + onOpenChange +}: AgentMapProjectContextMenuProps): React.JSX.Element | null { + const triggerRef = useRef(null) + const repos = useAppStore((state) => state.repos) + const projectGroups = useAppStore((state) => state.projectGroups) + const target = useMemo(() => { + if (request.projectId.startsWith(FOLDER_PROJECT_PREFIX)) { + const groupId = request.projectId.slice(FOLDER_PROJECT_PREFIX.length) + const groups = projectGroups.filter((group) => group.id === groupId) + return groups.length === 1 ? { kind: 'folder' as const, group: groups[0] } : null + } + const owners = repos.filter((repo) => repo.id === request.projectId) + return owners.length === 1 ? { kind: 'repo' as const, repo: owners[0] } : null + }, [projectGroups, repos, request.projectId]) + const repo = target?.kind === 'repo' ? target.repo : null + const sshStatus = useAppStore((state) => + repo?.connectionId ? (state.sshConnectionStates.get(repo.connectionId)?.status ?? null) : null + ) + const openModal = useAppStore((state) => state.openModal) + + useEffect(() => { + if (!target) { + onOpenChange?.(false) + return + } + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [onOpenChange, request, target]) + + if (!target) { + return null + } + const label = target.kind === 'repo' ? target.repo.displayName : target.group.name + const createState = + target.kind === 'repo' + ? getRepoHeaderCreateState({ repo: target.repo, label, sshStatus }) + : { + disabled: false, + tooltip: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ), + ariaLabel: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ) + } + + return ( +
+ + + + + + {label} + { + openModal( + 'new-workspace-composer', + target.kind === 'repo' + ? { initialRepoId: target.repo.id, telemetrySource: 'sidebar' } + : { initialProjectGroupId: target.group.id, telemetrySource: 'sidebar' } + ) + }} + > + + {createState.tooltip} + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx new file mode 100644 index 00000000000..ff81fdaca4f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx @@ -0,0 +1,29 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapProjectContextMenuRequest } from './AgentMapProjectContextMenu' + +const AgentMapProjectContextMenu = lazyWithRetry( + () => + import('./AgentMapProjectContextMenu').then((module) => ({ + default: module.AgentMapProjectContextMenu + })), + { reloadKey: 'agent-map-project-context-menu' } +) + +type AgentMapProjectContextMenuLoaderProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenuLoader({ + request, + onOpenChange +}: AgentMapProjectContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapProjectContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx new file mode 100644 index 00000000000..5cbf41b7abb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentMapLayout } from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' + +const LAYOUT: AgentMapLayout = { + projects: [ + { + id: 'repo-1', + name: 'Orca', + x: 120, + y: 120, + radius: 96, + worktrees: [], + agentCount: 1 + } + ], + width: 240, + height: 240, + topologyKey: 'repo-1' +} + +describe('AgentMapScene project labels', () => { + it('renders the configured repository image next to its name', () => { + const { container } = render( + + + + ) + + const label = container.querySelector('.agent-map-project-label')! + expect(label).toHaveTextContent('ORCA') + expect(label).toHaveClass('agent-map-project-label') + expect(label.querySelector('.agent-map-project-name')).toHaveTextContent('ORCA') + expect(label.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,AAAA') + expect(label.firstElementChild?.querySelector('img')).toBeInTheDocument() + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48') + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96') + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx new file mode 100644 index 00000000000..2719f4f7921 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -0,0 +1,243 @@ +import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { translate } from '@/i18n/i18n' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapLayout, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' +import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' + +type AgentMapSceneProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + zoom: number + labelScale: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktreeRing): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} C ${parent.x} ${branchY} ${child.x} ${branchY} ${child.x} ${endY}` +} + +type VisibleAgentLocation = { + agent: AgentMapAgentNode + worktreeId: string +} + +function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const dx = child.x - parent.x + const dy = child.y - parent.y + const distance = Math.hypot(dx, dy) + if (distance === 0) { + return `M ${parent.x} ${parent.y}` + } + const unitX = dx / distance + const unitY = dy / distance + return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}` +} + +/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ +export const AgentMapScene = memo(function AgentMapScene({ + layout, + repoIconsByRepoId, + zoom, + labelScale, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenProjectContextMenu, + onOpenWorkspaceContextMenu, + onAgentKeyDown +}: AgentMapSceneProps): React.JSX.Element { + const [activeWorktreeId, setActiveWorktreeId] = useState(null) + const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { + setActiveWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const visibleLabels = useMemo( + () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), + [labelScale, layout, mapScale] + ) + const visibleAgentsByPaneKey = useMemo(() => { + const agents = new Map() + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) { + continue + } + for (const agent of worktree.agents) { + agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id }) + } + } + } + return agents + }, [allowAggregation, layout, selectedPaneKey, zoom]) + return ( + <> + {layout.projects.map((project) => { + const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree])) + const projectLabelHalfWidth = project.radius * mapScale + const projectCountText = translate( + 'dashboardPopout.map.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { agents: project.agentCount, workspaces: project.worktrees.length } + ).toUpperCase() + const crossWorktreeLineage = project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) + return ( + + { + event.preventDefault() + event.stopPropagation() + onOpenProjectContextMenu(event, project) + } + : undefined + } + /> + + {project.worktrees.map((child) => { + const parent = child.parentId ? worktreesById.get(child.parentId) : undefined + return !parent || child.y <= parent.y ? null : ( + + ) + })} + + + {crossWorktreeLineage.map(({ parent, child }) => { + const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' + return ( + + ) + })} + + {project.worktrees.map((worktree) => ( + + ))} + + {project.worktrees.map((worktree) => ( + + ))} + + + +
+ + + {project.name.toUpperCase()} + +
+
+ {visibleLabels.projectCountIds.has(project.id) ? ( + + {projectCountText} + + ) : null} +
+
+ ) + })} + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx new file mode 100644 index 00000000000..e2208e0be0d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react' +import { Moon, Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' + +export type AgentMapSnapshotWorkspaceMenuRequest = { + id: number + worktreeId: string + worktreeName: string + launchableAgents: readonly TuiAgent[] + clientX: number + clientY: number +} + +type AgentMapSnapshotWorkspaceMenuProps = { + request: AgentMapSnapshotWorkspaceMenuRequest + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +/** + * The workspace right-click menu for surfaces without the app store — the + * pop-out window. Its actions are relayed to the main renderer, so it offers + * only what a snapshot can describe, not the full sidebar menu. + */ +export function AgentMapSnapshotWorkspaceMenu({ + request, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: AgentMapSnapshotWorkspaceMenuProps): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [request]) + + return ( +
+ + + + + + {request.worktreeName} + {onSpawnAgent ? ( + + + + {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} + + + {request.launchableAgents.map((agent) => ( + onSpawnAgent({ worktreeId: request.worktreeId, agent })} + > + + {getAgentLabel(agent)} + + ))} + + + ) : null} + {onSpawnAgent && onSleepWorkspace ? : null} + {onSleepWorkspace ? ( + onSleepWorkspace({ worktreeId: request.worktreeId })}> + + {translate('dashboardPopout.map.sleepWorkspace', 'Sleep')} + + ) : null} + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx new file mode 100644 index 00000000000..6e035dca692 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx @@ -0,0 +1,47 @@ +import { Focus, Minus, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +type AgentMapViewportControlsProps = { + zoom: number + onFit: () => void + onZoomIn: () => void + onZoomOut: () => void +} + +export function AgentMapViewportControls({ + zoom, + onFit, + onZoomIn, + onZoomOut +}: AgentMapViewportControlsProps): React.JSX.Element { + return ( +
+ + + {Math.round(zoom * 100)}% + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts new file mode 100644 index 00000000000..7275b3d274e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map workspace menu performance boundary', () => { + it('loads store-backed workspace actions only after a ring context request', () => { + const loader = source('AgentMapWorkspaceContextMenuLoader.tsx') + const menu = source('AgentMapWorkspaceContextMenu.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapWorkspaceContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapWorkspaceContextMenu\s*\}\s+from\s+['"]\.\/AgentMapWorkspaceContextMenu['"]/ + ) + expect(menu).toMatch(/import\('@\/components\/sidebar\/WorktreeContextMenu'\)/) + expect(menu).not.toMatch( + /import\s+WorktreeContextMenu\s+from\s+['"]@\/components\/sidebar\/WorktreeContextMenu['"]/ + ) + }) + + it('loads store-backed project actions only after a project context request', () => { + const loader = source('AgentMapProjectContextMenuLoader.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapProjectContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapProjectContextMenu\s*\}\s+from\s+['"]\.\/AgentMapProjectContextMenu['"]/ + ) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx new file mode 100644 index 00000000000..057a593da7e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx @@ -0,0 +1,375 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentMap } from './AgentMap' +import * as StoreSelectors from '@/store/selectors' + +const NOW = 2_000_000_000 +const EXECUTION_HOST_ID = 'runtime:env-1' as const +const initialState = useAppStore.getState() + +const repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Orca', + badgeColor: '#000000', + addedAt: NOW, + kind: 'git', + executionHostId: EXECUTION_HOST_ID +} satisfies Repo + +const worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/repo/worktrees/map', + displayName: 'Agent map', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + branch: 'refs/heads/agent-map', + head: 'abc123', + isBare: false, + isMainWorktree: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + hostId: EXECUTION_HOST_ID +} satisfies Worktree + +const collidingLocalWorktree = { + ...worktree, + path: '/local/repo/worktrees/map', + displayName: 'Local agent map', + hostId: 'local' +} satisfies Worktree + +const parentWorktree = { + ...worktree, + id: 'worktree-parent', + path: '/repo/worktrees/parent', + displayName: 'Parent worktree', + branch: 'refs/heads/parent' +} satisfies Worktree + +const folderProjectGroup = { + id: 'group-1', + name: 'Documentation', + parentPath: '/docs', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW +} satisfies ProjectGroup + +const card: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: repo.id, + worktreeId: worktree.id, + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: repo.displayName, + worktreeName: worktree.displayName, + startedAt: NOW - 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + workspaceKind: 'worktree' +} + +describe('Agent Map workspace context menu', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree, parentWorktree] }, + detectedWorktreesByRepo: {}, + projectGroups: [], + workspaceStatuses: [{ id: 'todo', label: 'Todo' }] + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('opens the shared sidebar workspace actions from a worktree ring', async () => { + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 120, + clientY: 140 + }) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + expect(screen.getByText('Update')).toBeInTheDocument() + expect(screen.getByText('Move to Status')).toBeInTheDocument() + expect(screen.getByText('Open in')).toBeInTheDocument() + expect(screen.getByText('Copy Path')).toBeInTheDocument() + expect(screen.getByText('Pin')).toBeInTheDocument() + expect(screen.getByText('Mark Unread')).toBeInTheDocument() + expect(screen.getByText('Sleep')).toBeInTheDocument() + expect(screen.getByText('Delete')).toBeInTheDocument() + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + }) + + it('deduplicates the same host owner across known and detected worktrees', async () => { + useAppStore.setState({ + detectedWorktreesByRepo: { + [repo.id]: { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: [ + { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } + }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('uses explicit SSH ownership instead of the paired hub repo host', async () => { + const sshHostId = 'ssh:provider-1' as const + const sshWorktree = { ...worktree, hostId: sshHostId } + useAppStore.setState({ worktreesByRepo: { [repo.id]: [sshWorktree] } }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('fails closed when bare-ID actions would span multiple execution hosts', async () => { + useAppStore.setState({ + worktreesByRepo: { [repo.id]: [collidingLocalWorktree, worktree] } + }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + ) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('clears a workspace request whose target disappeared', async () => { + useAppStore.setState({ worktreesByRepo: {} }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => expect(useWorktreeById).toHaveBeenCalled()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('releases the store-backed workspace menu after an ordinary close', async () => { + const getKnownWorktreeById = vi.fn(useAppStore.getState().getKnownWorktreeById) + useAppStore.setState({ getKnownWorktreeById }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByText('Workspace')).not.toBeInTheDocument()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + + getKnownWorktreeById.mockClear() + act(() => { + useAppStore.setState({ agentStatusEpoch: useAppStore.getState().agentStatusEpoch + 1 }) + }) + expect(getKnownWorktreeById).not.toHaveBeenCalled() + }) + + it('keeps shared-menu follow-up overlays mounted through their lifecycle', async () => { + render( + + {}} + /> + + ) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.contextMenu(ring) + const createGroup = await screen.findByText('New group from project', {}, { timeout: 5_000 }) + fireEvent.pointerDown(createGroup, { button: 0 }) + fireEvent.click(createGroup) + expect(await screen.findByRole('dialog', { name: 'New Project Group' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'New Project Group' })).not.toBeInTheDocument() + ) + + fireEvent.contextMenu(ring) + const setParent = await screen.findByText('Set Parent Worktree...', {}, { timeout: 5_000 }) + fireEvent.pointerDown(setParent, { button: 0 }) + fireEvent.click(setParent) + // Candidate rows are virtualized and measure 0 in happy-dom; the mounted + // search input is the picker's lifecycle signal. + expect(await screen.findByPlaceholderText('Search worktrees...')).toBeInTheDocument() + }) + + it('opens the existing worktree composer from a project ring', async () => { + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!, { + clientX: 100, + clientY: 110 + }) + fireEvent.click(await screen.findByText('Create new worktree for Orca', {}, { timeout: 5_000 })) + + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + expect(useAppStore.getState().modalData).toEqual({ + initialRepoId: repo.id, + telemetrySource: 'sidebar' + }) + }) + + it('opens the folder-workspace composer from a synthetic project ring', async () => { + useAppStore.setState({ projectGroups: [folderProjectGroup] }) + const folderCard = { + ...card, + repoId: `folder-workspace:${folderProjectGroup.id}`, + repoName: folderProjectGroup.name, + worktreeId: 'folder:folder-1', + worktreeName: 'Docs', + workspaceKind: 'folder' as const + } + const { container } = render( + + {}} + /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + fireEvent.click( + await screen.findByText('Create workspace for Documentation', {}, { timeout: 5_000 }) + ) + + expect(useAppStore.getState().modalData).toEqual({ + initialProjectGroupId: folderProjectGroup.id, + telemetrySource: 'sidebar' + }) + }) + + it('clears an ambiguous project request instead of choosing a repo host', async () => { + useAppStore.setState({ + repos: [repo, { ...repo, path: '/local/repo', executionHostId: 'local' }] + }) + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ repos: [repo] }) + }) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx new file mode 100644 index 00000000000..1ac20df44d2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx @@ -0,0 +1,167 @@ +import { Suspense, useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { useAppStore } from '@/store' +import { useWorktreeById } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +const WorktreeContextMenu = lazyWithRetry( + () => import('@/components/sidebar/WorktreeContextMenu'), + { reloadKey: 'agent-map-worktree-context-menu' } +) + +export type AgentMapWorkspaceContextMenuRequest = { + id: number + worktreeId: string + executionHostId?: ExecutionHostId + clientX: number + clientY: number + altKey: boolean +} + +type AgentMapWorkspaceContextMenuProps = { + request: AgentMapWorkspaceContextMenuRequest | null + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +function countWorkspaceOwners( + worktreeId: string | null, + state: Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'repos' + > +): number { + if (!worktreeId) { + return 0 + } + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return new Set( + state.folderWorkspaces + .filter((workspace) => workspace.id === scope.folderWorkspaceId) + .map( + (workspace) => + normalizeExecutionHostId(workspace.executionHostId) ?? + (workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : 'local') + ) + ).size + } + const repoOwnerIdsByRepoId = new Map>() + for (const repo of state.repos) { + const ownerId = getRepoExecutionHostId(repo) + const owners = repoOwnerIdsByRepoId.get(repo.id) + if (owners) { + owners.add(ownerId) + } else { + repoOwnerIdsByRepoId.set(repo.id, new Set([ownerId])) + } + } + const ownerIds = new Set() + const addOwner = (worktree: { repoId: string; hostId?: ExecutionHostId }): void => { + const directOwner = normalizeExecutionHostId(worktree.hostId) + if (directOwner) { + ownerIds.add(directOwner) + return + } + const repoOwnerIds = repoOwnerIdsByRepoId.get(worktree.repoId) + if (!repoOwnerIds) { + ownerIds.add('local') + return + } + for (const ownerId of repoOwnerIds) { + ownerIds.add(ownerId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + return ownerIds.size +} + +function ContextMenuTrigger({ + request +}: { + request: AgentMapWorkspaceContextMenuRequest +}): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + altKey: request.altKey, + button: 2 + }) + ) + }, [request]) + return +} + +export function AgentMapWorkspaceContextMenu({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuProps): React.JSX.Element | null { + const { worktreesByRepo, detectedWorktreesByRepo, folderWorkspaces, repos } = useAppStore( + useShallow((state) => ({ + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + repos: state.repos + })) + ) + const worktree = useWorktreeById(request?.worktreeId ?? null, request?.executionHostId) + const ownerCount = useMemo( + () => + countWorkspaceOwners(request?.worktreeId ?? null, { + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, + repos + }), + [detectedWorktreesByRepo, folderWorkspaces, repos, request?.worktreeId, worktreesByRepo] + ) + const unavailable = request !== null && (!worktree || ownerCount !== 1) + useEffect(() => { + if (unavailable) { + onLifecycleComplete?.() + } + }, [onLifecycleComplete, unavailable]) + if (!request || unavailable || !worktree) { + return null + } + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx new file mode 100644 index 00000000000..8ece35ad2ef --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapWorkspaceContextMenuRequest } from './AgentMapWorkspaceContextMenu' + +const AgentMapWorkspaceContextMenu = lazyWithRetry( + () => + import('./AgentMapWorkspaceContextMenu').then((module) => ({ + default: module.AgentMapWorkspaceContextMenu + })), + { reloadKey: 'agent-map-workspace-context-menu' } +) + +type AgentMapWorkspaceContextMenuLoaderProps = { + request: AgentMapWorkspaceContextMenuRequest + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +export function AgentMapWorkspaceContextMenuLoader({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapWorkspaceContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx new file mode 100644 index 00000000000..76398b29daf --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -0,0 +1,39 @@ +import { memo } from 'react' +import { translate } from '@/i18n/i18n' +import type { AgentMapWorktreeRing } from './agent-map-layout' + +type AgentMapWorktreeLabelProps = { + worktree: AgentMapWorktreeRing + visible: boolean + active: boolean + labelScale: number + mapScale: number +} + +export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ + worktree, + visible, + active, + labelScale, + mapScale +}: AgentMapWorktreeLabelProps): React.JSX.Element { + const showCount = visible && (active || worktree.radius * mapScale >= 80) + const agentCountText = translate( + 'dashboardPopout.map.agentCount', + worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', + { count: worktree.agents.length } + ) + return ( + + + {worktree.name} + + + {agentCountText} + + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx new file mode 100644 index 00000000000..9102b5e51c0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -0,0 +1,370 @@ +import { memo, useState, type MutableRefObject } from 'react' +import { Plus } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +type AgentMapWorktreeRingNodeProps = { + project: AgentMapProjectRing + worktree: AgentMapWorktreeRing + zoom: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgents?: readonly TuiAgent[] + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onLabelActiveChange: (worktreeId: string, active: boolean) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function formatDuration(minutes: number): string { + if (minutes < 1) { + return translate('dashboardPopout.card.time.justNow', 'just now') + } + if (minutes < 60) { + return translate('dashboardPopout.card.time.minutes', '{{count}}m', { + count: Math.floor(minutes) + }) + } + return translate('dashboardPopout.card.time.hours', '{{count}}h', { + count: Math.floor(minutes / 60) + }) +} + +function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` +} + +function agentName(card: DashboardCard): string { + return card.conversationName ?? (card.task.trim() || card.agentType) +} + +export function agentMapAttentionMarkerScale(mapScale: number): number { + const inverseScale = 1 / Math.max(mapScale, 0.001) + return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5) +} + +function WorktreeDetails({ + project, + worktree, + launchableAgents, + onSelectAgent, + onSpawnAgent, + onDone +}: Pick< + AgentMapWorktreeRingNodeProps, + 'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent' +> & { + onDone: () => void +}): React.JSX.Element { + const activeCount = + worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + return ( + +
+ {project.name} + {worktree.name} + + {translate( + 'dashboardPopout.map.worktreeSummary', + '{{total}} agents · {{active}} active · {{done}} done', + { + count: worktree.agents.length, + defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done', + defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done', + total: worktree.agents.length, + active: activeCount, + done: worktree.statusCounts.done + } + )} + +
+
+

+ {translate('dashboardPopout.map.runningAgents', 'Agents')} +

+
+ {worktree.agents.length === 0 ? ( +

+ {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} +

+ ) : ( + worktree.agents.map((agent) => ( + + )) + )} +
+
+ {onSpawnAgent ? ( +
+

+ {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} +

+ {launchableAgents && launchableAgents.length > 0 ? ( +
+ {launchableAgents.map((agent) => ( + + ))} +
+ ) : ( +

+ {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} +

+ )} +
+ ) : null} +
+ ) +} + +export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ + project, + worktree, + zoom, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgents, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenWorkspaceContextMenu, + onLabelActiveChange, + onAgentKeyDown +}: AgentMapWorktreeRingNodeProps): React.JSX.Element { + const [detailsOpen, setDetailsOpen] = useState(false) + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + const activeStatus = agentMapWorktreeActiveStatus(worktree.statusCounts) + const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + return ( + + onLabelActiveChange(worktree.id, true)} + onPointerLeave={() => onLabelActiveChange(worktree.id, false)} + onFocus={() => onLabelActiveChange(worktree.id, true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + onLabelActiveChange(worktree.id, false) + } + }} + > + {activeStatus ? ( + + setDetailsOpen(false)} + /> + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 6610efe2605..e788378a6db 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -2,27 +2,31 @@ import '@testing-library/jest-dom/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { DashboardCard, DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { AgentTerminalDialog } from './AgentTerminalDialog' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog' // Stub the preview so the assertion is on the props the dialog hands it, with // no xterm / IPC machinery in the way. vi.mock('./AgentTerminalPreview', () => ({ AgentTerminalPreview: ({ ptyId, - terminalInput + terminalInput, + className }: { ptyId: string terminalInput?: DashboardCardTerminalInput | null + className?: string }) => (
) })) @@ -85,4 +89,81 @@ describe('AgentTerminalDialog', () => { expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') }) + + it('labels acknowledged completions idle without review or pin controls', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Idle/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Keep visible' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Mark reviewed' })).not.toBeInTheDocument() + expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1') + }) + + it('labels unseen completions done', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Done/)).toBeInTheDocument() + }) + + it('preserves the execution host when revealing a colliding worktree ID', () => { + const onReveal = vi.fn() + render( + {}} + onReveal={onReveal} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + expect(onReveal).toHaveBeenCalledWith({ + repoId: 'r1', + worktreeId: 'w1', + executionHostId: 'runtime:env-1', + tabId: 'tab1', + leafId: 'leaf1' + }) + }) + + it('reuses the terminal surface as a non-modal adjacent panel', () => { + render( {}} onReveal={() => {}} />) + + expect(screen.getByRole('dialog', { name: 'wt' })).toHaveAttribute('data-state', 'open') + expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1') + expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument() + }) + + it('lets a nested Radix layer consume Escape before closing the panel', () => { + const onOpenChange = vi.fn() + render( + <> + {}} /> + + Details + Worktree details + + + ) + + fireEvent.keyDown(screen.getByText('Worktree details'), { key: 'Escape' }) + + expect(screen.queryByText('Worktree details')).not.toBeInTheDocument() + expect(onOpenChange).not.toHaveBeenCalled() + + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index ead43090820..a5eea33adc3 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,21 +1,21 @@ -import { useCallback } from 'react' +import { useEffect, useId } from 'react' import { SquareArrowOutUpRight, XIcon } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { agentStateLabel } from '@/components/AgentStateDot' -import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardRevealAgentArgs +} from '../../../../shared/dashboard-snapshot' import { AgentTerminalPreview } from './AgentTerminalPreview' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' /** Routing payload for focusing an agent's pane in the main window. */ -export type AgentRevealArgs = { - repoId: string - worktreeId: string - tabId: string - leafId: string | null -} +export type AgentRevealArgs = DashboardRevealAgentArgs type AgentTerminalDialogProps = { /** The agent shown in the dialog; null renders the dialog closed. */ @@ -26,6 +26,76 @@ type AgentTerminalDialogProps = { onReveal: (args: AgentRevealArgs) => void } +type AgentTerminalFrameProps = Omit & { + card: DashboardCard + title: React.ReactNode + previewClassName?: string +} + +function AgentTerminalFrame({ + card, + title, + previewClassName, + onOpenChange, + onReveal +}: AgentTerminalFrameProps): React.JSX.Element { + const reveal = (): void => { + onReveal({ + repoId: card.repoId, + worktreeId: card.worktreeId, + executionHostId: card.executionHostId, + tabId: card.tabId, + leafId: card.leafId + }) + onOpenChange(false) + } + + return ( + <> +
+ + + + {title} + + {formatAgentTypeLabel(card.agentType)} ·{' '} + {agentStateLabel(dashboardCardDisplayState(card))} + + +
+ {card.ptyId ? ( + + ) : ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ )} +
+ +
+ + ) +} + /** * The near-fullscreen live-terminal dialog for one agent. Hosted by the BOARD, * not the card: sending a message flips the agent's bucket, which remounts its @@ -38,18 +108,6 @@ export function AgentTerminalDialog({ onOpenChange, onReveal }: AgentTerminalDialogProps): React.JSX.Element { - const reveal = useCallback(() => { - if (!card) { - return - } - onReveal({ - repoId: card.repoId, - worktreeId: card.worktreeId, - tabId: card.tabId, - leafId: card.leafId - }) - }, [card, onReveal]) - return ( {card ? ( @@ -78,43 +136,68 @@ export function AgentTerminalDialog({ } }} > -
- - - - - {card.worktreeName} - - - {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} - - - - - {translate('dashboardPopout.terminal.close', 'Close')} - - -
- {card.ptyId ? ( - - ) : ( -
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} -
- )} -
- - - -
+ + {card.worktreeName} + + } + onOpenChange={onOpenChange} + onReveal={onReveal} + /> ) : null}
) } + +export function AgentTerminalPanel({ + card, + onOpenChange, + onReveal, + className +}: Omit & { + card: DashboardCard + className?: string +}): React.JSX.Element { + const titleId = useId() + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + event.key === 'Escape' && + !event.defaultPrevented && + !(event.target instanceof HTMLElement && event.target.closest('.xterm')) + ) { + onOpenChange(false) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onOpenChange]) + + return ( +
+ + {card.worktreeName} + + } + previewClassName="h-auto min-h-0 flex-1" + onOpenChange={onOpenChange} + onReveal={onReveal} + /> +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 8da354166f7..50d65df9193 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -49,11 +49,13 @@ function clamp(value: number, min: number, max: number): number { */ export function AgentTerminalPreview({ ptyId, - terminalInput = null + terminalInput = null, + className }: { ptyId: string /** Host-input facts relayed with the card; null routes bytes by client OS. */ terminalInput?: DashboardCardTerminalInput | null + className?: string }): React.JSX.Element { const containerRef = useRef(null) const terminalRef = useRef(null) @@ -444,7 +446,10 @@ export function AgentTerminalPreview({ // buffer is. The terminal keeps the pane's true dimensions and is scaled/ // clipped to fit; fitToBox anchors whichever end keeps the cursor in view.
{ptyGone ? ( diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index a857b298b50..557036c569a 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,9 +1,9 @@ -import { AgentKanbanBoard } from './AgentKanbanBoard' +import { useEffect, useState } from 'react' +import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard' import { useDashboardSnapshot } from './useDashboardSnapshot' type DashboardPopoutRootProps = { - /** The layout requested via popout.html?view=. Only "kanban" exists - * today; unknown views fall back to it. */ + /** The layout requested via popout.html?view=. */ view: string | null } @@ -13,5 +13,9 @@ type DashboardPopoutRootProps = { */ export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { const snapshot = useDashboardSnapshot() - return + const [view, setView] = useState(() => + _props.view === 'map' || _props.view === 'rings' ? 'map' : 'board' + ) + useEffect(() => window.api.dashboard.onViewRequested(setView), []) + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts index d7a4c132150..14470931ca8 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -1,4 +1,8 @@ -import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardCardReview, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' @@ -36,6 +40,34 @@ function cardSearchText(card: DashboardCard): string { .toLocaleLowerCase() } +function workspaceSearchText(workspace: DashboardWorkspace): string { + return [ + workspace.worktreeName, + workspace.repoName, + workspace.review ? `#${workspace.review.number}` : '' + ] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +function workspaceMatchesFilters( + workspace: DashboardWorkspace, + normalizedQuery: string, + filters: DashboardFilters +): boolean { + const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none') + return ( + (normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) && + (filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) && + (filters.workspaceStatuses.length === 0 || + (workspace.workspaceStatusId !== undefined && + filters.workspaceStatuses.includes(workspace.workspaceStatusId))) && + (filters.reviewStates.length === 0 || + (reviewState !== null && filters.reviewStates.includes(reviewState))) + ) +} + export function filterDashboardCards( cards: DashboardCard[], query: string, @@ -56,6 +88,17 @@ export function filterDashboardCards( }) } +export function filterDashboardWorkspaces( + workspaces: DashboardWorkspace[], + query: string, + filters: DashboardFilters +): DashboardWorkspace[] { + const normalizedQuery = query.trim().toLocaleLowerCase() + return workspaces.filter((workspace) => + workspaceMatchesFilters(workspace, normalizedQuery, filters) + ) +} + export function toggleDashboardFilter(values: T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts new file mode 100644 index 00000000000..56978357359 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts @@ -0,0 +1,54 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +const GOLDEN_ANGLE = 2.399963229728653 + +function stableHash(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +export function placeAgentMapAgents({ + worktreeId, + cards, + radius, + agentRadius, + now +}: { + worktreeId: string + cards: DashboardCard[] + radius: number + agentRadius: number + now: number +}): { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCard['dotState'] +}[] { + const availableRadius = Math.max(0, radius - agentRadius - 6) + const sorted = [...cards].sort((a, b) => + a.paneKey < b.paneKey ? -1 : a.paneKey > b.paneKey ? 1 : 0 + ) + const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2 + const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2 + + return sorted.map((card, index) => { + const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius + const angle = angleOffset + index * GOLDEN_ANGLE + return { + card, + x: Math.cos(angle) * orbit, + y: Math.sin(angle) * orbit, + radius: agentRadius, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + }) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts new file mode 100644 index 00000000000..4269759c99c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapState, countAgentMapCards, filterAgentMapCards } from './agent-map-filter' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'done', + dotState: 'done', + task: '', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 60_000, + finishedAt: NOW - 30_000, + stateChangedAt: NOW - 30_000, + unseen: false, + hostKind: 'local', + ...overrides + } +} + +describe('agent map filtering', () => { + it('keeps unseen completions done and settles acknowledged completions into idle', () => { + expect(agentMapState(card({ unseen: true }))).toBe('done') + expect(agentMapState(card({ unseen: false }))).toBe('idle') + expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle') + }) + + it('applies state and host filters independently', () => { + const hidden = card({ paneKey: 'hidden', repoId: 'hidden', hostKind: 'ssh', unseen: true }) + const visible = filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'ssh' + }) + + expect(visible).toEqual([hidden]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['idle']), + hostFilter: 'ssh' + }) + ).toEqual([]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'local' + }) + ).toEqual([]) + }) + + it('counts all four display states', () => { + const cards = [ + card({ paneKey: 'done-new', unseen: true }), + card({ paneKey: 'done-seen', unseen: false }), + card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }), + card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null }) + ] + + expect(countAgentMapCards(cards)).toEqual({ + attention: 1, + working: 1, + done: 1, + idle: 1 + }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts new file mode 100644 index 00000000000..3a137ff93bd --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -0,0 +1,48 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardHostKind +} from '../../../../shared/dashboard-snapshot' + +export type AgentMapState = 'attention' | 'working' | 'done' | 'idle' +export type AgentMapHostFilter = 'all' | DashboardCardHostKind +export type AgentMapCounts = Record + +export function agentMapState(card: DashboardCard): AgentMapState { + const state = dashboardCardDisplayState(card) + if (state === 'blocked' || state === 'waiting') { + return 'attention' + } + return state +} + +export function filterAgentMapCards({ + cards, + enabledStates, + hostFilter +}: { + cards: DashboardCard[] + enabledStates: ReadonlySet + hostFilter: AgentMapHostFilter +}): DashboardCard[] { + // Project filtering lives in the shared toolbar filter, which has already + // narrowed these cards. + return cards.filter( + (card) => + (hostFilter === 'all' || (card.hostKind ?? 'local') === hostFilter) && + enabledStates.has(agentMapState(card)) + ) +} + +export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts { + const counts: AgentMapCounts = { + attention: 0, + working: 0, + done: 0, + idle: 0 + } + for (const card of cards) { + counts[agentMapState(card)] += 1 + } + return counts +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts new file mode 100644 index 00000000000..9403974b62b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map glow performance boundary', () => { + it('uses one conditional SVG halo per active entity without filter effects', () => { + const component = source('AgentMapWorktreeRingNode.tsx') + + expect(component.match(/data-agent-map-worktree-status-glow/g)).toHaveLength(1) + expect(component.match(/data-agent-map-agent-working-glow/g)).toHaveLength(1) + expect(component).not.toMatch(/ { + const css = source('agent-map.css') + const glowRules = css.match(/\.agent-map-(?:worktree-status|agent-working)-glow\s*\{[^}]+\}/gs) + + expect(glowRules).toHaveLength(2) + for (const rule of glowRules ?? []) { + expect(rule).toContain('pointer-events: none') + expect(rule).toContain('vector-effect: non-scaling-stroke') + expect(rule).not.toMatch(/filter:|animation:|transition:/) + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts new file mode 100644 index 00000000000..28c71c881a1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import type { + AgentMapLayout, + AgentMapProjectRing, + AgentMapStatusCounts, + AgentMapWorktreeRing +} from './agent-map-layout' + +function statusCounts(overrides: Partial = {}): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0, ...overrides } +} + +function worktree(overrides: Partial = {}): AgentMapWorktreeRing { + const counts = overrides.statusCounts ?? statusCounts({ working: 1 }) + const total = Object.values(counts).reduce((sum, value) => sum + value, 0) + return { + id: 'worktree-a', + worktreeId: 'worktree-a', + executionHostId: undefined, + name: 'alpha', + workspaceKind: 'worktree', + x: 0, + y: 0, + radius: 62, + // Sparse placeholders keep tests that only care about label-to-label collisions concise. + agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'], + statusCounts: counts, + quiet: counts.idle === total, + ...overrides + } +} + +function layoutOf( + worktrees: AgentMapWorktreeRing[], + project: Partial = {} +): AgentMapLayout { + return { + projects: [ + { + id: 'project-1', + name: 'orca', + x: 0, + // Parked far above the workspaces so the project label is not itself + // the thing under test unless a case moves it. + y: -4_000, + radius: 100, + worktrees, + agentCount: worktrees.reduce((sum, item) => sum + item.agents.length, 0), + ...project + } + ], + width: 900, + height: 560, + topologyKey: 'test' + } +} + +describe('selectVisibleAgentMapLabels', () => { + it('keeps both labels when they are far enough apart', () => { + const layout = layoutOf([ + worktree({ id: 'a', x: -400, y: 0 }), + worktree({ id: 'b', name: 'beta', x: 400, y: 0 }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds].sort()).toEqual(['a', 'b']) + }) + + it('drops the lower-priority label when two would overlap', () => { + // Same anchor point: the two labels are drawn on top of each other. + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['busy']) + }) + + it('hides a workspace title that would cover an agent', () => { + const coveringAgent = { x: 0, y: -48, radius: 20 } + const covered = worktree({ + id: 'covered', + agents: [coveringAgent] as AgentMapWorktreeRing['agents'] + }) + + const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) + + expect(labels.worktreeIds.size).toBe(0) + expect(labels.worktreeAgentSafeIds.size).toBe(0) + }) + + it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const labels = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...labels.worktreeIds]).toEqual(['busy']) + expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) + }) + + it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { + const layout = layoutOf([ + worktree({ id: 'blocked', x: 0, y: 0, statusCounts: statusCounts({ blocked: 1 }) }), + worktree({ + id: 'working', + name: 'beta', + x: 0, + y: 0, + statusCounts: statusCounts({ working: 9 }) + }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['blocked']) + }) + + it('hides all-idle workspace labels until the ring is large on screen', () => { + const idle = worktree({ id: 'idle', x: 0, y: 0, statusCounts: statusCounts({ idle: 2 }) }) + + expect(selectVisibleAgentMapLabels(layoutOf([idle]), 1, 0.5).worktreeIds.size).toBe(0) + expect([...selectVisibleAgentMapLabels(layoutOf([idle]), 1, 1).worktreeIds]).toEqual(['idle']) + }) + + it('drops the project count rather than a workspace name when they collide', () => { + // The workspace ring's name lands on the project's count line. + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 42 })], { + x: 0, + y: 0, + radius: 40, + agentCount: 1 + }) + + const { worktreeIds, projectCountIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['a']) + expect(projectCountIds.size).toBe(0) + }) + + it('keeps the project count when nothing is in its way', () => { + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 0 })]) + + expect([...selectVisibleAgentMapLabels(layout, 1, 1).projectCountIds]).toEqual(['project-1']) + }) + + it('admits more labels as zooming in shrinks their world footprint', () => { + const worktrees = Array.from({ length: 6 }, (_unused, index) => + worktree({ id: `w-${index}`, name: `workspace-${index}`, x: index * 90, y: 0 }) + ) + + const zoomedOut = selectVisibleAgentMapLabels(layoutOf(worktrees), 4, 0.25).worktreeIds + const zoomedIn = selectVisibleAgentMapLabels(layoutOf(worktrees), 1, 1).worktreeIds + + expect(zoomedOut.size).toBeLessThan(zoomedIn.size) + expect(zoomedIn.size).toBe(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts new file mode 100644 index 00000000000..020d8453dd2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -0,0 +1,273 @@ +import type { AgentMapLayout, AgentMapWorktreeRing } from './agent-map-layout' + +/** Metrics for the label styles in agent-map.css. Estimating text extents from + * the character count avoids a per-frame DOM measure of every label. */ +const WORKTREE_LABEL_FONT_PX = 12 +const PROJECT_LABEL_FONT_PX = 13 +const COUNT_FONT_PX = 11 +const GLYPH_WIDTH_RATIO = 0.56 +/** Uppercase project text runs wider per glyph than a mixed-case worktree name. */ +const UPPERCASE_GLYPH_WIDTH_RATIO = 0.66 +const ASCENT_RATIO = 0.8 +const DESCENT_RATIO = 0.2 +const PROJECT_LABEL_ICON_PX = 16 +/** Local-unit breathing room so two labels never appear to touch. */ +const LABEL_GAP_X_PX = 3 +const LABEL_GAP_Y_PX = 1 +const AGENT_LABEL_CLEARANCE_PX = 3 +/** Baselines the scene renders at, relative to each label group's origin. */ +const WORKTREE_LABEL_BASELINE = 18 +const COUNT_BASELINE = 32 +const PROJECT_NAME_TOP = 3 +const PROJECT_NAME_BOTTOM = 21 +/** Past this many candidates the pass stops admitting labels; a map that dense + * is unreadable long before the cap, and this bounds the work. */ +const MAX_LABEL_CANDIDATES = 600 +const DECLUTTER_GRID_PX = 96 + +/** A label box in world units, matching what the scene actually renders. */ +type LabelBox = { + left: number + right: number + top: number + bottom: number +} + +type LabelGrid = Map> + +export type AgentMapVisibleLabels = { + /** Worktree ring ids whose name can render without colliding. */ + worktreeIds: Set + /** Worktree names that hover/focus may reveal without covering an agent. */ + worktreeAgentSafeIds: Set + /** Project ids whose agent/workspace count line still has room. The count is + * the first thing dropped: it repeats what the filter rail already says. */ + projectCountIds: Set +} + +function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): number { + return text.length * fontPx * ratio +} + +export function agentMapWorktreeLabelWidth(text: string): number { + return textWidth(text, WORKTREE_LABEL_FONT_PX) +} + +export function agentMapCountLabelWidth(text: string): number { + return textWidth(text, COUNT_FONT_PX) +} + +function boxesOverlap(a: LabelBox, b: LabelBox): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom +} + +function addBox(grid: LabelGrid, box: LabelBox): void { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + let column = grid.get(x) + if (!column) { + column = new Map() + grid.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(box) + } else { + column.set(y, [box]) + } + } + } +} + +function collides(grid: LabelGrid, box: LabelBox): boolean { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + const column = grid.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const placed of column.get(y) ?? []) { + if (boxesOverlap(box, placed)) { + return true + } + } + } + } + return false +} + +/** Projects a centered label from its group's local units into world space + * through the same scale the scene applies to the group. */ +function centeredBox( + centerX: number, + anchorY: number, + scale: number, + width: number, + localTop: number, + localBottom: number +): LabelBox { + const halfWidth = (width / 2 + LABEL_GAP_X_PX) * scale + return { + left: centerX - halfWidth, + right: centerX + halfWidth, + top: anchorY + (localTop - LABEL_GAP_Y_PX) * scale, + bottom: anchorY + (localBottom + LABEL_GAP_Y_PX) * scale + } +} + +/** Box for a centered given its baseline in the group's local units. */ +function baselineBox( + centerX: number, + anchorY: number, + scale: number, + text: string, + fontPx: number, + baseline: number, + widthRatio = GLYPH_WIDTH_RATIO +): LabelBox { + return centeredBox( + centerX, + anchorY, + scale, + textWidth(text, fontPx, widthRatio), + baseline - fontPx * ASCENT_RATIO, + baseline + fontPx * DESCENT_RATIO + ) +} + +/** Attention outranks volume: a blocked workspace keeps its name when a large + * idle neighbour has to drop its own. */ +function labelPriority(worktree: AgentMapWorktreeRing): number { + const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting + return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length +} + +/** Worth drawing before collisions are considered: all-idle workspaces stay + * silent until their ring is big enough on screen to be worth naming. */ +function isLabelCandidate(worktree: AgentMapWorktreeRing, mapScale: number): boolean { + return !worktree.quiet || worktree.radius * mapScale >= 56 +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function addAgentExclusionBoxes(grid: LabelGrid, layout: AgentMapLayout, mapScale: number): void { + const clearance = AGENT_LABEL_CLEARANCE_PX / Math.max(mapScale, 0.001) + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + for (const agent of worktree.agents) { + if (!agent) { + continue + } + const radius = agent.radius + clearance + addBox(grid, { + left: agent.x - radius, + right: agent.x + radius, + top: agent.y - radius, + bottom: agent.y + radius + }) + } + } + } +} + +/** + * Picks the labels that can render without stacking on each other. Labels draw + * at a fixed screen size, so which ones fit depends on zoom but never on pan — + * keeping this pan-independent is what lets the scene stay memoized during a + * drag. Project names claim space first as the map's coarsest landmark, + * workspace names next in priority order, and project counts take what is left. + */ +export function selectVisibleAgentMapLabels( + layout: AgentMapLayout, + labelScale: number, + mapScale: number +): AgentMapVisibleLabels { + const agentGrid: LabelGrid = new Map() + addAgentExclusionBoxes(agentGrid, layout, mapScale) + const grid: LabelGrid = new Map() + addAgentExclusionBoxes(grid, layout, mapScale) + for (const project of layout.projects) { + const name = project.name.toUpperCase() + addBox( + grid, + centeredBox( + project.x, + project.y - project.radius, + labelScale, + PROJECT_LABEL_ICON_PX + textWidth(name, PROJECT_LABEL_FONT_PX, UPPERCASE_GLYPH_WIDTH_RATIO), + PROJECT_NAME_TOP, + PROJECT_NAME_BOTTOM + ) + ) + } + + const candidates: AgentMapWorktreeRing[] = [] + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (isLabelCandidate(worktree, mapScale)) { + candidates.push(worktree) + } + } + } + candidates.sort((a, b) => { + const byPriority = labelPriority(b) - labelPriority(a) + if (byPriority !== 0) { + return byPriority + } + const byRadius = b.radius - a.radius + return byRadius !== 0 ? byRadius : compareStable(a.id, b.id) + }) + + const worktreeIds = new Set() + const worktreeAgentSafeIds = new Set() + for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { + const box = baselineBox( + worktree.x, + worktree.y - worktree.radius, + labelScale, + worktree.name, + WORKTREE_LABEL_FONT_PX, + WORKTREE_LABEL_BASELINE + ) + if (collides(agentGrid, box)) { + continue + } + worktreeAgentSafeIds.add(worktree.id) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + worktreeIds.add(worktree.id) + } + + const projectCountIds = new Set() + for (const project of layout.projects) { + const count = `${project.agentCount} AGENTS · ${project.worktrees.length} WORKSPACES` + const box = baselineBox( + project.x, + project.y - project.radius, + labelScale, + count, + COUNT_FONT_PX, + COUNT_BASELINE, + UPPERCASE_GLYPH_WIDTH_RATIO + ) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + projectCountIds.add(project.id) + } + + return { worktreeIds, worktreeAgentSafeIds, projectCountIds } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts new file mode 100644 index 00000000000..69238b73cf3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts @@ -0,0 +1,53 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +export function refreshAgentMapMetadata( + geometry: AgentMapLayout, + cards: DashboardCard[], + now: number +): AgentMapLayout { + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const projects = geometry.projects.map((project) => { + let projectName = project.name + let agentCount = 0 + const worktrees = project.worktrees.map((worktree) => { + let worktreeName = worktree.name + let workspaceKind = worktree.workspaceKind + const statusCounts = emptyStatusCounts() + const agents = worktree.agents.flatMap((agent) => { + const card = cardsByPaneKey.get(agent.card.paneKey) + if (!card) { + return [] + } + projectName = card.repoName + worktreeName = card.worktreeName + workspaceKind = card.workspaceKind ?? 'worktree' + agentCount += 1 + statusCounts[agentMapNodeStatus(card)] += 1 + return [ + { + ...agent, + card, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + ] + }) + return { + ...worktree, + name: worktreeName, + workspaceKind, + agents, + statusCounts, + quiet: statusCounts.idle === agents.length + } + }) + return { ...project, name: projectName, worktrees, agentCount } + }) + return { ...geometry, projects } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts new file mode 100644 index 00000000000..52c47be08f5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { + deriveAgentMapLayout, + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_RING_HEADER_HEIGHT, + AGENT_MAP_WORKTREE_GAP, + agentMapDurationMinutes, + agentMapNodeStatus, + updateAgentMapLayout +} from './agent-map-layout' +import type * as WorktreePackingModule from './agent-map-worktree-packing' + +const packWorktrees = vi.hoisted(() => vi.fn()) +vi.mock('./agent-map-worktree-packing', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + packAgentMapWorktrees: (...args: Parameters) => { + packWorktrees() + return actual.packAgentMapWorktrees(...args) + } + } +}) + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty worktree', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +describe('agent map layout', () => { + it('lays out agentless workspaces and preserves their workspace lineage', () => { + const layout = deriveAgentMapLayout([], NOW, [ + workspace({ worktreeId: 'parent', worktreeName: 'Parent' }), + workspace({ + worktreeId: 'child', + worktreeName: 'Child', + parentWorktreeId: 'parent' + }) + ]) + const project = layout.projects[0] + const parent = project.worktrees.find((item) => item.worktreeId === 'parent')! + const child = project.worktrees.find((item) => item.worktreeId === 'child')! + + expect(project.agentCount).toBe(0) + expect(parent.agents).toEqual([]) + expect(child.parentId).toBe(parent.id) + expect(child.y).toBeGreaterThan(parent.y) + }) + + it('derives project containment, workspace containment, and every agent node', () => { + const cards = [ + card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'c', repoId: 'repo-a', worktreeId: 'wt-b' }), + card({ + paneKey: 'd', + repoId: 'repo-b', + repoName: 'Mobile', + worktreeId: 'wt-c' + }) + ] + const layout = deriveAgentMapLayout(cards, NOW) + + expect(layout.projects.map((project) => project.id)).toEqual(['repo-a', 'repo-b']) + expect(layout.projects[0].worktrees.map((worktree) => worktree.worktreeId)).toEqual([ + 'wt-a', + 'wt-b' + ]) + expect( + layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey)) + ) + ).toEqual(['a', 'b', 'c', 'd']) + + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + expect( + Math.hypot(worktree.x - project.x, worktree.y - project.y) + worktree.radius + ).toBeLessThan(project.radius) + for (const agent of worktree.agents) { + expect( + Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius + ).toBeLessThan(worktree.radius) + } + } + } + }) + + it('keeps exact worktree IDs on different hosts in separate rings', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'local', executionHostId: 'local' }), + card({ paneKey: 'remote', executionHostId: 'runtime:env-1' }) + ], + NOW + ) + + expect(layout.projects[0].worktrees).toHaveLength(2) + expect( + layout.projects[0].worktrees.map(({ worktreeId, executionHostId }) => ({ + worktreeId, + executionHostId + })) + ).toEqual( + expect.arrayContaining([ + { worktreeId: 'worktree-1', executionHostId: 'local' }, + { worktreeId: 'worktree-1', executionHostId: 'runtime:env-1' } + ]) + ) + }) + + it('reserves project and workspace header bands above dense ring contents', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 24 }, (_unused, index) => + card({ paneKey: `dense-${index.toString().padStart(2, '0')}` }) + ), + NOW + ) + const project = layout.projects[0] + const worktree = project.worktrees[0] + const projectTop = project.y - project.radius + const worktreeTop = worktree.y - worktree.radius + const agentTop = Math.min(...worktree.agents.map((agent) => agent.y - agent.radius)) + + expect(worktreeTop - projectTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + expect(agentTop - worktreeTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + }) + + it('keeps sparse project and workspace rings compact around their header bands', () => { + const single = deriveAgentMapLayout([card()], NOW).projects[0] + const four = deriveAgentMapLayout( + Array.from({ length: 4 }, (_unused, index) => card({ paneKey: `agent-${index}` })), + NOW + ).projects[0] + + expect(single.worktrees[0].radius).toBeLessThanOrEqual(72) + expect(single.radius).toBeLessThanOrEqual(104) + expect(four.worktrees[0].radius).toBeLessThanOrEqual(100) + }) + + it.each([ + ['single', [card()]], + [ + 'sparse', + [ + card({ paneKey: 'a', repoId: 'repo-a' }), + card({ paneKey: 'b', repoId: 'repo-b', worktreeId: 'worktree-2' }) + ] + ] + ])('centers %s project content within the minimum world', (_, cards) => { + const layout = deriveAgentMapLayout(cards, NOW) + const left = Math.min(...layout.projects.map((project) => project.x - project.radius)) + const right = Math.max(...layout.projects.map((project) => project.x + project.radius)) + const top = Math.min(...layout.projects.map((project) => project.y - project.radius)) + const bottom = Math.max(...layout.projects.map((project) => project.y + project.radius)) + + expect(layout.width).toBe(900) + expect(layout.height).toBe(560) + expect((left + right) / 2).toBeCloseTo(layout.width / 2) + expect((top + bottom) / 2).toBeCloseTo(layout.height / 2) + }) + + it('places spawned descendants beneath their direct parent inside the workspace', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + card({ paneKey: 'child-a', parentPaneKey: 'parent' }), + card({ paneKey: 'child-b', parentPaneKey: 'parent' }), + card({ paneKey: 'grandchild', parentPaneKey: 'child-a' }) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + const parent = agents.get('parent')! + const childA = agents.get('child-a')! + const childB = agents.get('child-b')! + const grandchild = agents.get('grandchild')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const agent of worktree.agents) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + } + }) + + it('packs high-fanout spawned children into a compact deterministic cluster', () => { + const cards = [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 29 }, (_, index) => + card({ + paneKey: `child-${index.toString().padStart(2, '0')}`, + parentPaneKey: 'parent' + }) + ) + ] + const first = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const second = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const parent = first.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = first.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + + expect(new Set(children.map((child) => child.y.toFixed(3))).size).toBeGreaterThan(4) + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect( + Math.max(...children.map((child) => child.x)) - Math.min(...children.map((child) => child.x)) + ).toBeLessThan(500) + expect(first.radius).toBeLessThan(350) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + expect(Math.hypot(child.x - other.x, child.y - other.y)).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 2 + ) + } + } + expect(first.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y }))).toEqual( + second.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y })) + ) + }) + + it('places visible child worktrees beneath their direct parent', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child-a', + worktreeId: 'child-a-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'child-b', + worktreeId: 'child-b-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'grandchild', + worktreeId: 'grandchild-worktree', + parentWorktreeId: 'child-a-worktree' + }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const childA = worktrees.get('child-a-worktree')! + const childB = worktrees.get('child-b-worktree')! + const grandchild = worktrees.get('grandchild-worktree')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const [index, worktree] of layout.projects[0].worktrees.entries()) { + for (const other of layout.projects[0].worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('clusters cross-worktree spawned agents without inventing workspace lineage', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const child = worktrees.get('child-worktree')! + + expect(child.clusterParentId).toBe(parent.id) + expect(child.parentId).toBeUndefined() + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('clusters spawned agents whose parent is in another project', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', repoId: 'repo-parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + repoId: 'repo-child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', repoId: 'repo-unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const projects = new Map(layout.projects.map((project) => [project.id, project])) + const parent = projects.get('repo-parent')! + const child = projects.get('repo-child')! + + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('repacks cached geometry when a worktree parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a', worktreeId: 'parent-a' }), + card({ paneKey: 'parent-b', worktreeId: 'parent-b' }), + card({ paneKey: 'child', worktreeId: 'child', parentWorktreeId: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.worktreeId === 'child' + ? { ...candidate, parentWorktreeId: 'parent-b' } + : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + }) + + it('repacks cached geometry when a spawn parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a' }), + card({ paneKey: 'parent-b' }), + card({ paneKey: 'child', parentPaneKey: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.paneKey === 'child' ? { ...candidate, parentPaneKey: 'parent-b' } : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + expect(updated.layout.topologyKey).not.toBe(initial.layout.topologyKey) + }) + + it('keeps positions stable across routine status and duration updates', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-a', startedAt: NOW - 2 * 60_000 }), + card({ paneKey: 'c', worktreeId: 'wt-b' }) + ] + const initial = deriveAgentMapLayout(initialCards, NOW) + const updated = deriveAgentMapLayout( + [ + { ...initialCards[0], bucket: 'attention', dotState: 'waiting' }, + { ...initialCards[1], startedAt: NOW - 45 * 60_000 }, + initialCards[2] + ], + NOW + ) + const initialAgents = initial.projects[0].worktrees[0].agents + const updatedAgents = updated.projects[0].worktrees[0].agents + const initialWorktrees = initial.projects[0].worktrees + const updatedWorktrees = updated.projects[0].worktrees + + expect(updated.topologyKey).toBe(initial.topologyKey) + expect(updatedWorktrees.map(({ x, y }) => ({ x, y }))).toEqual( + initialWorktrees.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents.map(({ x, y }) => ({ x, y }))).toEqual( + initialAgents.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents[1].radius).toBe(initialAgents[1].radius) + }) + + it('reuses packed geometry while refreshing live card metadata', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-b' }) + ] + const initial = updateAgentMapLayout(null, initialCards, NOW) + packWorktrees.mockClear() + const updatedCards = [ + { ...initialCards[0], dotState: 'waiting' as const, worktreeName: 'Renamed' }, + { ...initialCards[1], startedAt: NOW - 60 * 60_000 } + ] + const updated = updateAgentMapLayout(initial.cache, updatedCards, NOW + 60_000) + + expect(updated.cache).toBe(initial.cache) + expect(packWorktrees).not.toHaveBeenCalled() + expect(initial.cache.geometry).toBe(initial.layout) + expect(updated.cache.packingGeneration).toBe(1) + expect(updated.layout.projects[0].worktrees[0].name).toBe('Renamed') + expect(updated.layout.projects[0].worktrees[0].statusCounts.waiting).toBe(1) + expect(updated.layout.projects[0].worktrees[1].agents[0].durationMinutes).toBe(61) + + const topologyChanged = updateAgentMapLayout( + updated.cache, + [...updatedCards, card({ paneKey: 'c', worktreeId: 'wt-c' })], + NOW + ) + expect(topologyChanged.cache).not.toBe(updated.cache) + expect(packWorktrees).toHaveBeenCalled() + expect(topologyChanged.cache.packingGeneration).toBe(2) + }) + + it('packs worktree rings tightly without a square grid', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 36 }, (_, index) => + card({ + paneKey: `agent-${index}`, + worktreeId: `worktree-${index.toString().padStart(2, '0')}` + }) + ), + NOW + ) + const project = layout.projects[0] + + expect(project.radius).toBeLessThan(700) + expect( + new Set(project.worktrees.map((worktree) => worktree.x.toFixed(3))).size + ).toBeGreaterThan(12) + expect( + new Set(project.worktrees.map((worktree) => worktree.y.toFixed(3))).size + ).toBeGreaterThan(12) + for (const [index, worktree] of project.worktrees.entries()) { + for (const other of project.worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('uses one agent size while retaining elapsed duration', () => { + const finished = card({ + startedAt: NOW - 30 * 60_000, + finishedAt: NOW - 20 * 60_000 + }) + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'just-started', startedAt: NOW }), + card({ paneKey: 'long-running', startedAt: NOW - 24 * 60 * 60_000 }) + ], + NOW + ) + + expect(layout.projects[0].worktrees[0].agents.map((agent) => agent.radius)).toEqual([ + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_AGENT_RADIUS + ]) + expect(agentMapDurationMinutes(finished, NOW)).toBe(10) + }) + + it('maps acknowledged completions to idle independently from elapsed time', () => { + for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) { + expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState) + } + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done') + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('idle') + const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 }) + const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 }) + expect(agentMapNodeStatus(shortBlocked)).toBe(agentMapNodeStatus(longBlocked)) + }) + + it('marks only operationally quiet workspaces for semantic aggregation', () => { + const quiet = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `quiet-${index}`, dotState: index === 0 ? 'done' : 'idle' }) + ), + NOW + ) + const active = deriveAgentMapLayout([card({ paneKey: 'active', dotState: 'working' })], NOW) + const unseenDone = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `done-${index}`, dotState: 'done', unseen: true }) + ), + NOW + ) + + expect(quiet.projects[0].worktrees[0].quiet).toBe(true) + expect(active.projects[0].worktrees[0].quiet).toBe(false) + expect(unseenDone.projects[0].worktrees[0].quiet).toBe(false) + }) + + it('places hundreds of agents in one workspace without overlap', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 400 }, (_, index) => card({ paneKey: `agent-${index}` })), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + + expect(worktree.agents).toHaveLength(400) + let minimumDistance = Number.POSITIVE_INFINITY + for (const [index, agent] of worktree.agents.entries()) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + for (const other of worktree.agents.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(agent.x - other.x, agent.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) + + it('keeps deeply nested spawn lineage finite without recursive stack growth', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 5_000 }, (_, index) => + card({ + paneKey: `agent-${index.toString().padStart(4, '0')}`, + parentPaneKey: + index === 0 ? undefined : `agent-${(index - 1).toString().padStart(4, '0')}` + }) + ), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + expect(worktree.agents).toHaveLength(5_000) + expect(Number.isFinite(worktree.radius)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000_000) + for (const agent of worktree.agents) { + if (agent.card.parentPaneKey) { + expect(agent.y).toBeGreaterThan(agents.get(agent.card.parentPaneKey)!.y) + } + } + }) + + it('wraps very large spawn fanout without overlap', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 300 }, (_, index) => + card({ paneKey: `child-${index}`, parentPaneKey: 'parent' }) + ) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const parent = worktree.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = worktree.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + let minimumDistance = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(child.x - other.x, child.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts new file mode 100644 index 00000000000..68602f95c04 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -0,0 +1,308 @@ +import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snapshot' +import { placeAgentMapAgents } from './agent-map-agent-placement' +import { layoutAgentMapLineage } from './agent-map-lineage-layout' +import { refreshAgentMapMetadata } from './agent-map-layout-metadata' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' +import { placeAgentMapProjects } from './agent-map-project-placement' +import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering' +import { + agentMapCardTopologyIdentity, + agentMapWorkspaceIdentity, + agentMapWorkspaceTopologyIdentity, + agentMapWorktreeIdentity, + agentMapWorktreeIdentityFromParts +} from './agent-map-workspace-identity' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +type DashboardCard = DashboardSnapshotTypes.DashboardCard +type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState +type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +export { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +export const AGENT_MAP_AGENT_RADIUS = 20 +export const AGENT_MAP_AGGREGATE_ZOOM = 1.15 +export const AGENT_MAP_RING_HEADER_HEIGHT = 40 + +const PROJECT_PADDING = 12 +const WORLD_MARGIN = 32 +const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 + +export type AgentMapStatusCounts = Record + +export type AgentMapAgentNode = { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCardDotState +} + +export type AgentMapWorktreeRing = { + id: string + parentId?: string + /** Layout-only parent chosen from agent spawn edges; does not imply workspace lineage. */ + clusterParentId?: string + worktreeId: string + executionHostId: DashboardCard['executionHostId'] + name: string + workspaceKind: NonNullable + x: number + y: number + radius: number + agents: AgentMapAgentNode[] + statusCounts: AgentMapStatusCounts + quiet: boolean +} + +export type AgentMapProjectRing = { + id: string + name: string + x: number + y: number + radius: number + worktrees: AgentMapWorktreeRing[] + agentCount: number +} + +export type AgentMapLayout = { + projects: AgentMapProjectRing[] + width: number + height: number + topologyKey: string +} + +export type AgentMapLayoutCache = { + topologyKey: string + geometry: AgentMapLayout + packingGeneration: number +} + +type LocalWorktree = Omit & { x: number; y: number } +type LocalProject = Omit & { + x: number + y: number + clusterParentId?: string + worktrees: LocalWorktree[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function agentMapTopologyKey( + cards: DashboardCard[], + workspaces: DashboardWorkspace[] = [] +): string { + return [ + ...cards.map((card) => `a:${agentMapCardTopologyIdentity(card)}`), + ...workspaces.map((workspace) => `w:${agentMapWorkspaceTopologyIdentity(workspace)}`) + ] + .sort(compareStable) + .join('|') +} + +export function shouldAggregateAgentMapWorktree( + worktree: AgentMapWorktreeRing, + zoom: number, + allowAggregation = true +): boolean { + return ( + allowAggregation && + zoom < AGENT_MAP_AGGREGATE_ZOOM && + worktree.quiet && + worktree.agents.length > 3 + ) +} + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +function worktreeRadius(agentCount: number): number { + return Math.max( + 52, + 24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8) + ) +} + +function buildLocalWorktree( + id: string, + cards: DashboardCard[], + now: number, + workspace?: DashboardWorkspace +): LocalWorktree { + const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS) + const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length) + const radius = contentRadius + RING_CONTENT_OFFSET + const statusCounts = emptyStatusCounts() + for (const card of cards) { + statusCounts[agentMapNodeStatus(card)] += 1 + } + const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId + return { + id, + parentId: parentWorktreeId + ? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId) + : undefined, + worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id, + executionHostId, + name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id, + workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree', + x: 0, + y: 0, + radius, + agents: ( + lineageLayout?.agents.map(({ card, x, y }) => ({ + card, + x, + y, + radius: AGENT_MAP_AGENT_RADIUS, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + })) ?? + placeAgentMapAgents({ + worktreeId: id, + cards, + radius: contentRadius, + agentRadius: AGENT_MAP_AGENT_RADIUS, + now + }) + ).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })), + statusCounts, + quiet: statusCounts.idle === cards.length + } +} + +function buildLocalProject( + id: string, + cards: DashboardCard[], + workspaces: DashboardWorkspace[], + cardsByPaneKey: ReadonlyMap, + now: number +): LocalProject { + const byWorktree = new Map() + for (const card of cards) { + const identity = agentMapWorktreeIdentity(card) + const current = byWorktree.get(identity) + if (current) { + current.push(card) + } else { + byWorktree.set(identity, [card]) + } + } + const workspacesById = new Map( + workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace]) + ) + for (const workspaceId of workspacesById.keys()) { + if (!byWorktree.has(workspaceId)) { + byWorktree.set(workspaceId, []) + } + } + const positionedWorktrees = layoutAgentMapWorktreeLineage( + [...byWorktree.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([worktreeId, worktreeCards]) => ({ + ...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)), + clusterParentId: selectAgentMapSpawnParentContainer( + worktreeCards, + cardsByPaneKey, + agentMapWorktreeIdentity + ) + })) + ) + const contentRadius = Math.max( + 84, + ...positionedWorktrees.map( + (worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING + ) + ) + const worktrees = positionedWorktrees.map((worktree) => ({ + ...worktree, + y: worktree.y + RING_CONTENT_OFFSET + })) + return { + id, + name: cards[0]?.repoName ?? workspaces[0]?.repoName ?? id, + x: 0, + y: 0, + clusterParentId: selectAgentMapSpawnParentContainer( + cards, + cardsByPaneKey, + (card) => card.repoId + ), + radius: contentRadius + RING_CONTENT_OFFSET, + worktrees, + agentCount: cards.length + } +} + +export function deriveAgentMapLayout( + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): AgentMapLayout { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (cards.length === 0 && workspaces.length === 0) { + return { projects: [], width: 900, height: 560, topologyKey } + } + const byProject = new Map() + for (const card of cards) { + const current = byProject.get(card.repoId) ?? { cards: [], workspaces: [] } + current.cards.push(card) + byProject.set(card.repoId, current) + } + for (const workspace of workspaces) { + const current = byProject.get(workspace.repoId) ?? { cards: [], workspaces: [] } + current.workspaces.push(workspace) + byProject.set(workspace.repoId, current) + } + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const localProjects = [...byProject.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([projectId, project]) => + buildLocalProject(projectId, project.cards, project.workspaces, cardsByPaneKey, now) + ) + const framed = placeAgentMapProjects(localProjects, 900, 560, WORLD_MARGIN) + const projects = framed.projects.map((project): AgentMapProjectRing => { + return { + ...project, + worktrees: project.worktrees.map((worktree) => ({ + ...worktree, + x: project.x + worktree.x, + y: project.y + worktree.y, + agents: worktree.agents.map((agent) => ({ + ...agent, + x: project.x + worktree.x + agent.x, + y: project.y + worktree.y + agent.y + })) + })) + } + }) + return { projects, width: framed.width, height: framed.height, topologyKey } +} + +export function updateAgentMapLayout( + cache: AgentMapLayoutCache | null, + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): { cache: AgentMapLayoutCache; layout: AgentMapLayout } { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (!cache || cache.topologyKey !== topologyKey) { + const geometry = deriveAgentMapLayout(cards, now, workspaces) + return { + cache: { + topologyKey, + geometry, + packingGeneration: (cache?.packingGeneration ?? 0) + 1 + }, + layout: geometry + } + } + const layout = refreshAgentMapMetadata(cache.geometry, cards, now) + return { cache, layout } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts new file mode 100644 index 00000000000..f5d3665c483 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts @@ -0,0 +1,224 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { packAgentMapWorktrees } from './agent-map-worktree-packing' + +const HORIZONTAL_GAP = 54 +const VERTICAL_GAP = 58 +const FAMILY_PADDING = 8 +const WORKTREE_PADDING = 6 +const COMPACT_FANOUT_THRESHOLD = 12 +const MAX_EXACT_LINEAGE_AGENTS = 256 + +export type AgentMapLineagePosition = { + card: DashboardCard + x: number + y: number +} + +type AgentMapAgentFamily = { + id: string + x: number + y: number + radius: number + agents: AgentMapLineagePosition[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily( + id: string, + agents: AgentMapLineagePosition[], + nodeRadius: number +): AgentMapAgentFamily { + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const agent of agents) { + left = Math.min(left, agent.x - nodeRadius) + right = Math.max(right, agent.x + nodeRadius) + top = Math.min(top, agent.y - nodeRadius) + bottom = Math.max(bottom, agent.y + nodeRadius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + let radius = 0 + for (const agent of agents) { + agent.x -= centerX + agent.y -= centerY + radius = Math.max(radius, Math.hypot(agent.x, agent.y) + nodeRadius + FAMILY_PADDING) + } + return { id, x: 0, y: 0, radius, agents } +} + +function buildCompactFanoutFamily( + root: DashboardCard, + children: DashboardCard[], + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const columns = Math.ceil(Math.sqrt(children.length)) + const width = (Math.min(columns, children.length) - 1) * HORIZONTAL_GAP + const agents: AgentMapLineagePosition[] = [{ card: root, x: 0, y: 0 }] + emitted.add(root.paneKey) + for (const [index, child] of children.entries()) { + emitted.add(child.paneKey) + agents.push({ + card: child, + x: (index % columns) * HORIZONTAL_GAP - width / 2, + y: (Math.floor(index / columns) + 1) * VERTICAL_GAP + }) + } + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function buildFamily( + root: DashboardCard, + childrenByParent: ReadonlyMap, + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const agents: AgentMapLineagePosition[] = [] + let leafIndex = 0 + const rootChildren = (childrenByParent.get(root.paneKey) ?? []).filter( + (child) => !emitted.has(child.paneKey) + ) + if ( + rootChildren.length >= COMPACT_FANOUT_THRESHOLD && + rootChildren.every((child) => (childrenByParent.get(child.paneKey) ?? []).length === 0) + ) { + return buildCompactFanoutFamily(root, rootChildren, nodeRadius, emitted) + } + + const placeSubtree = ( + card: DashboardCard, + depth: number, + ancestors: ReadonlySet + ): number => { + if (ancestors.has(card.paneKey) || emitted.has(card.paneKey)) { + return leafIndex++ * HORIZONTAL_GAP + } + emitted.add(card.paneKey) + const nextAncestors = new Set(ancestors) + nextAncestors.add(card.paneKey) + const children = (childrenByParent.get(card.paneKey) ?? []).filter( + (child) => !nextAncestors.has(child.paneKey) && !emitted.has(child.paneKey) + ) + const childXs = children.map((child) => placeSubtree(child, depth + 1, nextAncestors)) + const x = + childXs.length > 0 + ? (Math.min(...childXs) + Math.max(...childXs)) / 2 + : leafIndex++ * HORIZONTAL_GAP + agents.push({ card, x, y: depth * VERTICAL_GAP }) + return x + } + + placeSubtree(root, 0, new Set()) + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function layoutBoundedLineage( + sorted: DashboardCard[], + childrenByParent: ReadonlyMap, + childPaneKeys: ReadonlySet, + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } { + const levels: DashboardCard[][] = [] + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.paneKey)) { + continue + } + const stack = [{ card: seed, depth: 0 }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.card.paneKey)) { + continue + } + emitted.add(entry.card.paneKey) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.card) + const children = childrenByParent.get(entry.card.paneKey) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].paneKey)) { + stack.push({ card: children[index], depth: entry.depth + 1 }) + } + } + } + } + + const agents: AgentMapLineagePosition[] = [] + let rowIndex = 0 + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + const width = (row.length - 1) * HORIZONTAL_GAP + for (const [index, card] of row.entries()) { + agents.push({ card, x: index * HORIZONTAL_GAP - width / 2, y: rowIndex * VERTICAL_GAP }) + } + rowIndex += 1 + } + } + const family = encloseFamily(sorted[0].paneKey, agents, nodeRadius) + family.agents.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)) + return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) } +} + +export function layoutAgentMapLineage( + cards: DashboardCard[], + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } | null { + const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey)) + const cardsByPaneKey = new Map(sorted.map((card) => [card.paneKey, card])) + const childrenByParent = new Map() + const childPaneKeys = new Set() + + for (const card of sorted) { + const parentPaneKey = card.parentPaneKey + if (!parentPaneKey || parentPaneKey === card.paneKey || !cardsByPaneKey.has(parentPaneKey)) { + continue + } + childPaneKeys.add(card.paneKey) + childrenByParent.set(parentPaneKey, [...(childrenByParent.get(parentPaneKey) ?? []), card]) + } + if (childPaneKeys.size === 0) { + return null + } + if (sorted.length > MAX_EXACT_LINEAGE_AGENTS) { + return layoutBoundedLineage(sorted, childrenByParent, childPaneKeys, nodeRadius) + } + + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + const families: AgentMapAgentFamily[] = [] + for (const root of roots) { + if (!emitted.has(root.paneKey)) { + families.push(buildFamily(root, childrenByParent, nodeRadius, emitted)) + } + } + for (const card of sorted) { + if (!emitted.has(card.paneKey)) { + families.push(buildFamily(card, childrenByParent, nodeRadius, emitted)) + } + } + const packed = packAgentMapWorktrees(families) + return { + agents: packed + .flatMap((family) => + family.agents.map((agent) => ({ + ...agent, + x: family.x + agent.x, + y: family.y + agent.y + })) + ) + .sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)), + radius: Math.max( + 52, + ...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING) + ) + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts new file mode 100644 index 00000000000..d8c4baf6edc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { deriveAgentMapLayout } from './agent-map-layout' +import { navigableAgentMapAgents } from './agent-map-navigation' + +const NOW = 2_000_000_000 + +function card(paneKey: string, worktreeId: string, idle: boolean): DashboardCard { + return { + paneKey, + ptyId: `pty-${paneKey}`, + agentType: 'codex', + bucket: idle ? 'idle' : 'working', + dotState: idle ? 'idle' : 'working', + task: '', + repoId: 'repo-1', + worktreeId, + tabId: `tab-${paneKey}`, + leafId: `leaf-${paneKey}`, + repoName: 'Orca', + worktreeName: worktreeId, + startedAt: NOW - 60_000, + finishedAt: idle ? NOW - 30_000 : null, + stateChangedAt: NOW - 30_000, + unseen: false + } +} + +describe('agent map keyboard navigation visibility', () => { + it('excludes every aggregated worktree and restores a selected quiet worktree', () => { + const quietCards = Array.from({ length: 5 }, (_, index) => + card(`quiet-${index}`, 'quiet-worktree', true) + ) + const active = card('active', 'active-worktree', false) + const layout = deriveAgentMapLayout([...quietCards, active], NOW) + + expect( + navigableAgentMapAgents(layout, 1, true, null).map((agent) => agent.card.paneKey) + ).toEqual(['active']) + expect(navigableAgentMapAgents(layout, 1, true, 'quiet-0')).toHaveLength(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts new file mode 100644 index 00000000000..ae3cfdbea2b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts @@ -0,0 +1,53 @@ +import type { AgentMapAgentNode, AgentMapLayout } from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' + +type Direction = { x: number; y: number } + +export function agentMapAgents(layout: AgentMapLayout): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents) + ) +} + +export function navigableAgentMapAgents( + layout: AgentMapLayout, + zoom: number, + allowAggregation: boolean, + selectedPaneKey: string | null +): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => { + const containsSelection = worktree.agents.some( + (agent) => agent.card.paneKey === selectedPaneKey + ) + return !containsSelection && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + ? [] + : worktree.agents + }) + ) +} + +export function nextDirectionalAgent( + current: AgentMapAgentNode, + agents: AgentMapAgentNode[], + direction: Direction +): AgentMapAgentNode | null { + let best: { agent: AgentMapAgentNode; score: number } | null = null + for (const candidate of agents) { + if (candidate.card.paneKey === current.card.paneKey) { + continue + } + const dx = candidate.x - current.x + const dy = candidate.y - current.y + const forward = dx * direction.x + dy * direction.y + if (forward <= 0) { + continue + } + const sideways = Math.abs(dx * direction.y - dy * direction.x) + const score = forward + sideways * 2 + if (!best || score < best.score) { + best = { agent: candidate, score } + } + } + return best?.agent ?? null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts new file mode 100644 index 00000000000..09532614d6d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -0,0 +1,17 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardDotState +} from '../../../../shared/dashboard-snapshot' + +export function agentMapDurationMinutes(card: DashboardCard, now: number): number { + if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) { + return 0 + } + const end = card.finishedAt && card.finishedAt >= card.startedAt ? card.finishedAt : now + return Math.max(0, (end - card.startedAt) / 60_000) +} + +export function agentMapNodeStatus(card: DashboardCard): DashboardCardDotState { + return dashboardCardDisplayState(card) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts new file mode 100644 index 00000000000..83aefde2f06 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts @@ -0,0 +1,94 @@ +export const AGENT_MAP_WORKTREE_GAP = 8 +export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001 + +const PACKING_GRID_SIZE = 128 + +export type AgentMapPackableCircle = { + id: string + x: number + y: number + radius: number +} + +type PackingSpatialGrid = { + cells: Map> + cellSize: number +} + +export type AgentMapPackingSpatialIndex = Map + +function packingGridLevel(radius: number): number { + return Math.max( + 0, + Math.ceil(Math.log2((radius * 2 + AGENT_MAP_WORKTREE_GAP) / PACKING_GRID_SIZE)) + ) +} + +export function addAgentMapPackingCircle( + index: AgentMapPackingSpatialIndex, + circle: AgentMapPackableCircle +): void { + const level = packingGridLevel(circle.radius) + let grid = index.get(level) + if (!grid) { + grid = { cells: new Map(), cellSize: PACKING_GRID_SIZE * 2 ** level } + index.set(level, grid) + } + const left = Math.floor((circle.x - circle.radius) / grid.cellSize) + const right = Math.floor((circle.x + circle.radius) / grid.cellSize) + const top = Math.floor((circle.y - circle.radius) / grid.cellSize) + const bottom = Math.floor((circle.y + circle.radius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + let column = grid.cells.get(x) + if (!column) { + column = new Map() + grid.cells.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(circle) + } else { + column.set(y, [circle]) + } + } + } +} + +export function agentMapPackingCircleOverlaps( + candidate: Pick, + index: AgentMapPackingSpatialIndex +): boolean { + const searchRadius = candidate.radius + AGENT_MAP_WORKTREE_GAP + const checked = new Set() + for (const grid of index.values()) { + const left = Math.floor((candidate.x - searchRadius) / grid.cellSize) + const right = Math.floor((candidate.x + searchRadius) / grid.cellSize) + const top = Math.floor((candidate.y - searchRadius) / grid.cellSize) + const bottom = Math.floor((candidate.y + searchRadius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + const column = grid.cells.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const circle of column.get(y) ?? []) { + if (checked.has(circle)) { + continue + } + checked.add(circle) + if ( + Math.hypot(candidate.x - circle.x, candidate.y - circle.y) < + candidate.radius + + circle.radius + + AGENT_MAP_WORKTREE_GAP - + AGENT_MAP_PACKING_SCORE_TOLERANCE + ) { + return true + } + } + } + } + } + return false +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts new file mode 100644 index 00000000000..413102bb3f0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts @@ -0,0 +1,50 @@ +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +const PROJECT_GAP = 32 + +type ProjectCircle = { + id: string + x: number + y: number + radius: number + clusterParentId?: string +} + +function placeUnlinkedProjects(projects: T[]): T[] { + let cursorX = 0 + return projects.map((project) => { + const positioned = { ...project, x: cursorX + project.radius, y: 0 } + cursorX += project.radius * 2 + PROJECT_GAP + return positioned + }) +} + +export function placeAgentMapProjects( + projects: T[], + minimumWidth: number, + minimumHeight: number, + worldMargin: number +): { projects: T[]; width: number; height: number } { + const positioned = projects.some((project) => project.clusterParentId) + ? layoutAgentMapWorktreeLineage(projects) + : placeUnlinkedProjects(projects) + const left = Math.min(...positioned.map((project) => project.x - project.radius)) + const right = Math.max(...positioned.map((project) => project.x + project.radius)) + const top = Math.min(...positioned.map((project) => project.y - project.radius)) + const bottom = Math.max(...positioned.map((project) => project.y + project.radius)) + const naturalWidth = right - left + worldMargin * 2 + const naturalHeight = bottom - top + worldMargin * 2 + const width = Math.max(minimumWidth, naturalWidth) + const height = Math.max(minimumHeight, naturalHeight) + const offsetX = worldMargin - left + (width - naturalWidth) / 2 + const offsetY = worldMargin - top + (height - naturalHeight) / 2 + return { + projects: positioned.map((project) => ({ + ...project, + x: project.x + offsetX, + y: project.y + offsetY + })), + width, + height + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts new file mode 100644 index 00000000000..a341fdd1767 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts @@ -0,0 +1,27 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function selectAgentMapSpawnParentContainer( + cards: readonly DashboardCard[], + cardsByPaneKey: ReadonlyMap, + containerIdentity: (card: DashboardCard) => string +): string | undefined { + const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined + const linkCounts = new Map() + for (const card of cards) { + const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined + const parentContainerId = parent ? containerIdentity(parent) : undefined + if (!parentContainerId || parentContainerId === ownContainerId) { + continue + } + linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1) + } + return [...linkCounts] + .sort(([leftId, leftCount], [rightId, rightCount]) => + rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId) + ) + .at(0)?.[0] +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts new file mode 100644 index 00000000000..6fe03f6f585 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts @@ -0,0 +1,56 @@ +export type AgentMapViewport = { + center: { x: number; y: number } + zoom: number +} + +type ViewportTransitionOptions = { + from: AgentMapViewport + to: AgentMapViewport + durationMs: number + onFrame: (viewport: AgentMapViewport) => void + onComplete?: () => void +} + +function interpolate(from: number, to: number, progress: number): number { + return from + (to - from) * progress +} + +export function startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete +}: ViewportTransitionOptions): () => void { + let frameId: number | null = null + let startedAt: number | null = null + let cancelled = false + const tick = (now: number): void => { + if (cancelled) { + return + } + startedAt ??= now + const progress = Math.min(1, (now - startedAt) / durationMs) + const eased = 1 - (1 - progress) ** 3 + onFrame({ + center: { + x: interpolate(from.center.x, to.center.x, eased), + y: interpolate(from.center.y, to.center.y, eased) + }, + zoom: interpolate(from.zoom, to.zoom, eased) + }) + if (progress < 1) { + frameId = requestAnimationFrame(tick) + } else { + frameId = null + onComplete?.() + } + } + frameId = requestAnimationFrame(tick) + return () => { + cancelled = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts new file mode 100644 index 00000000000..5ceaf4e93eb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts @@ -0,0 +1,29 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' + +export function agentMapCardTopologyIdentity(card: DashboardCard): string { + const parentPaneKey = card.parentPaneKey ?? '' + const parentWorktreeId = card.parentWorktreeId ?? '' + const executionHostId = card.executionHostId ?? '' + return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${executionHostId.length}:${executionHostId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorkspaceTopologyIdentity(workspace: DashboardWorkspace): string { + const parentWorktreeId = workspace.parentWorktreeId ?? '' + return `${workspace.repoId.length}:${workspace.repoId}${workspace.worktreeId.length}:${workspace.worktreeId}${workspace.executionHostId.length}:${workspace.executionHostId}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorktreeIdentityFromParts( + worktreeId: string, + executionHostId: DashboardCard['executionHostId'] +): string { + const hostId = executionHostId ?? '' + return `${worktreeId.length}:${worktreeId}${hostId.length}:${hostId}` +} + +export function agentMapWorktreeIdentity(card: DashboardCard): string { + return agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId) +} + +export function agentMapWorkspaceIdentity(workspace: DashboardWorkspace): string { + return agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts new file mode 100644 index 00000000000..1a9593af373 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { EMPTY_DASHBOARD_FILTERS } from './agent-board-filtering' +import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: null, + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: '', + repoId: 'repo-1', + worktreeId: 'occupied', + tabId: 'tab-1', + leafId: null, + repoName: 'Orca', + worktreeName: 'Occupied', + executionHostId: 'local', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty', + repoName: 'Orca', + worktreeName: 'Empty child', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + workspaceStatusId: 'planned', + ...overrides + } +} + +describe('agent map workspace visibility', () => { + it('returns only workspaces that have no dashboard card on the same host', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [card()], + workspaces: [ + workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }), + workspace(), + workspace({ + worktreeId: 'occupied', + worktreeName: 'Remote twin', + hostKind: 'ssh', + executionHostId: 'ssh:build-box' + }) + ], + query: '', + filters: EMPTY_DASHBOARD_FILTERS + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin']) + }) + + it('applies search and workspace filters to agentless workspaces', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [], + workspaces: [ + workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }), + workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' }) + ], + query: 'listener', + filters: { + projects: ['repo-1'], + workspaceStatuses: ['planned'], + reviewStates: ['open'] + } + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Listener security']) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts new file mode 100644 index 00000000000..311dd95e2f6 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts @@ -0,0 +1,25 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { filterDashboardWorkspaces, type DashboardFilters } from './agent-board-filtering' +import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity' + +export function selectAgentlessMapWorkspaces({ + cards, + workspaces, + query, + filters +}: { + cards: DashboardCard[] + workspaces: DashboardWorkspace[] + query: string + filters: DashboardFilters +}): DashboardWorkspace[] { + const occupiedWorkspaceIds = new Set( + cards.map((card) => agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId)) + ) + return filterDashboardWorkspaces(workspaces, query, filters).filter( + (workspace) => + !occupiedWorkspaceIds.has( + agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) + ) + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts new file mode 100644 index 00000000000..c02eb68ed27 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { AgentMapStatusCounts } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +function counts(overrides: Partial = {}): AgentMapStatusCounts { + return { blocked: 0, waiting: 0, working: 0, done: 0, idle: 0, ...overrides } +} + +describe('agentMapWorktreeActiveStatus', () => { + it('prioritizes attention over working', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1 }))).toBe('waiting') + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1, blocked: 1 }))).toBe( + 'blocked' + ) + }) + + it('uses working only when no agent needs attention', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 1, done: 2 }))).toBe('working') + expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts new file mode 100644 index 00000000000..925a80940e5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts @@ -0,0 +1,15 @@ +import type { AgentMapStatusCounts } from './agent-map-layout' + +export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working' + +export function agentMapWorktreeActiveStatus( + counts: AgentMapStatusCounts +): AgentMapWorktreeActiveStatus | null { + if (counts.blocked > 0) { + return 'blocked' + } + if (counts.waiting > 0) { + return 'waiting' + } + return counts.working > 0 ? 'working' : null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts new file mode 100644 index 00000000000..8bfb9fc28d4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +function buildChain(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(4, '0')}`, + parentId: index === 0 ? undefined : `worktree-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + })) +} + +function buildComb(spineCount: number) { + const worktrees: ReturnType = [] + for (let index = 0; index < spineCount; index += 1) { + const suffix = index.toString().padStart(4, '0') + worktrees.push({ + id: `spine-${suffix}`, + parentId: index === 0 ? undefined : `spine-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + }) + if (index < spineCount - 1) { + worktrees.push({ + id: `leaf-${suffix}`, + parentId: `spine-${suffix}`, + radius: 24, + x: 0, + y: 0 + }) + } + } + return worktrees +} + +function layoutWithNumericMapSetCount(worktrees: ReturnType) { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + return { layout: layoutAgentMapWorktreeLineage(worktrees), numericMapSets } + } finally { + Map.prototype.set = set + } +} + +function layoutWithWorktreePushCount(count: number) { + const push = Array.prototype.push + let worktreePushes = 0 + Array.prototype.push = function (...items: unknown[]): number { + worktreePushes += items.filter( + (item) => + typeof item === 'object' && + item !== null && + 'id' in item && + typeof item.id === 'string' && + item.id.startsWith('worktree-') + ).length + return Reflect.apply(push, this, items) + } + try { + return { + layout: layoutAgentMapWorktreeLineage(buildChain(count)), + worktreePushes + } + } finally { + Array.prototype.push = push + } +} + +describe('layoutAgentMapWorktreeLineage', () => { + it('keeps branched and linear family coordinates deterministic', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'root', x: 0, y: 0, radius: 40 }, + { id: 'child-a', parentId: 'root', x: 0, y: 0, radius: 30 }, + { id: 'grandchild-a', parentId: 'child-a', x: 0, y: 0, radius: 25 }, + { id: 'child-b', parentId: 'root', x: 0, y: 0, radius: 45 }, + { id: 'second-root', x: 0, y: 0, radius: 35 }, + { id: 'second-child', parentId: 'second-root', x: 0, y: 0, radius: 20 } + ]) + + expect(layout).toEqual([ + { + id: 'child-a', + parentId: 'root', + radius: 30, + x: -7.740689238053122, + y: 42.47172405948656 + }, + { + id: 'child-b', + parentId: 'root', + radius: 45, + x: 69.93546272327787, + y: 175.54837055839306 + }, + { + id: 'grandchild-a', + parentId: 'child-a', + radius: 25, + x: -7.740689238053122, + y: 125.47172405948656 + }, + { id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 }, + { + id: 'second-child', + parentId: 'second-root', + radius: 20, + x: -112.9872940755618, + y: -73.65876088400047 + }, + { + id: 'second-root', + radius: 35, + x: -112.9872940755618, + y: -156.65876088400046 + } + ]) + }) + + it('flattens a 1,000-worktree lineage once', () => { + const { layout, worktreePushes } = layoutWithWorktreePushCount(1_000) + + expect(layout).toHaveLength(1_000) + expect(worktreePushes).toBeLessThan(5_000) + for (let index = 1; index < layout.length; index += 1) { + expect(layout[index].y).toBeGreaterThan(layout[index - 1].y) + expect(layout[index].y - layout[index - 1].y).toBeGreaterThanOrEqual( + layout[index].radius + layout[index - 1].radius + AGENT_MAP_WORKTREE_GAP + ) + } + }) + + it.each([ + [399, 200], + [999, 500] + ])('avoids spatial-grid expansion for a %i-worktree comb', (expectedCount, spineCount) => { + const worktrees = buildComb(spineCount) + const { layout, numericMapSets } = layoutWithNumericMapSetCount(worktrees) + + expect(layout).toHaveLength(expectedCount) + expect(numericMapSets).toBeLessThan(10) + expect(layoutAgentMapWorktreeLineage(worktrees)).toEqual(layout) + }) + + it('keeps a deeply branched lineage finite without recursive stack growth', () => { + const worktrees = buildComb(2_500) + const layout = layoutAgentMapWorktreeLineage(worktrees) + const byId = new Map(layout.map((worktree) => [worktree.id, worktree])) + + expect(layout).toHaveLength(4_999) + expect( + layout.every( + (worktree) => + Number.isFinite(worktree.x) && + Number.isFinite(worktree.y) && + Math.abs(worktree.x) < 1_000_000 && + Math.abs(worktree.y) < 1_000_000 + ) + ).toBe(true) + for (const worktree of layout) { + if (worktree.parentId) { + expect(worktree.y).toBeGreaterThan(byId.get(worktree.parentId)!.y) + } + } + }) + + it('wraps very large worktree fanout without overlap', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 300 }, (_, index) => ({ + id: `child-${index}`, + parentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'parentId' in worktree && worktree.parentId === 'parent' + ) + let minimumGap = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(child.x - other.x, child.y - other.y) - child.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP) + }) + + it('packs high-fanout spawn clusters without forcing every workspace below the coordinator', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 13 }, (_, index) => ({ + id: `child-${index.toString().padStart(2, '0')}`, + clusterParentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'clusterParentId' in worktree && worktree.clusterParentId === 'parent' + ) + + expect(children.some((child) => child.y <= parent.y)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts new file mode 100644 index 00000000000..1a6e2875b65 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts @@ -0,0 +1,279 @@ +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +const LINEAGE_VERTICAL_GAP = 28 +const MAX_HIERARCHICAL_CLUSTER_FANOUT = 12 +const MAX_EXACT_LINEAGE_WORKTREES = 256 + +type LineageWorktree = { + id: string + parentId?: string + clusterParentId?: string + x: number + y: number + radius: number +} + +type WorktreeFamily = { + id: string + x: number + y: number + radius: number + worktrees: T[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily(id: string, worktrees: T[]): WorktreeFamily { + const left = Math.min(...worktrees.map((worktree) => worktree.x - worktree.radius)) + const right = Math.max(...worktrees.map((worktree) => worktree.x + worktree.radius)) + const top = Math.min(...worktrees.map((worktree) => worktree.y - worktree.radius)) + const bottom = Math.max(...worktrees.map((worktree) => worktree.y + worktree.radius)) + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + for (const worktree of worktrees) { + worktree.x -= centerX + worktree.y -= centerY + } + return { + id, + x: 0, + y: 0, + radius: Math.max( + ...worktrees.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius) + ), + worktrees + } +} + +function buildFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set, + ancestors: ReadonlySet +): WorktreeFamily { + emitted.add(root.id) + const nextAncestors = new Set(ancestors) + nextAncestors.add(root.id) + const children = (childrenByParent.get(root.id) ?? []).filter( + (child) => !nextAncestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length === 0) { + return { + id: root.id, + x: 0, + y: 0, + radius: root.radius, + worktrees: [{ ...root, x: 0, y: 0 }] + } + } + + const childFamilies = packAgentMapWorktrees( + children.map((child) => buildExactFamily(child, childrenByParent, emitted)) + ) + const childLeft = Math.min(...childFamilies.map((family) => family.x - family.radius)) + const childRight = Math.max(...childFamilies.map((family) => family.x + family.radius)) + const childTop = Math.min(...childFamilies.map((family) => family.y - family.radius)) + const childOffsetX = -(childLeft + childRight) / 2 + const childOffsetY = root.radius + LINEAGE_VERTICAL_GAP - childTop + const worktrees = [{ ...root, x: 0, y: 0 }] + for (const family of childFamilies) { + for (const worktree of family.worktrees) { + worktrees.push({ + ...worktree, + x: worktree.x + family.x + childOffsetX, + y: worktree.y + family.y + childOffsetY + }) + } + } + return encloseFamily(root.id, worktrees) +} + +function collectLinearFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: ReadonlySet +): T[] | null { + const worktrees: T[] = [] + const ancestors = new Set() + let current: T | undefined = root + while (current) { + worktrees.push(current) + ancestors.add(current.id) + const children = (childrenByParent.get(current.id) ?? []).filter( + (child) => !ancestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length > 1) { + return null + } + current = children[0] + } + return worktrees +} + +function buildLinearFamily(worktrees: T[]): WorktreeFamily { + const positioned = worktrees.map((worktree) => ({ ...worktree, x: 0, y: 0 })) + let radius = worktrees.at(-1)?.radius ?? 0 + for (let index = worktrees.length - 2; index >= 0; index -= 1) { + positioned[index].y = -(radius + LINEAGE_VERTICAL_GAP / 2) + radius += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + let familyCenterY = 0 + for (let index = 0; index < positioned.length; index += 1) { + positioned[index].y += familyCenterY + familyCenterY += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + return { id: worktrees[0].id, x: 0, y: 0, radius, worktrees: positioned } +} + +function buildExactFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set +): WorktreeFamily { + const linearFamily = collectLinearFamily(root, childrenByParent, emitted) + if (!linearFamily) { + return buildFamily(root, childrenByParent, emitted, new Set()) + } + for (const worktree of linearFamily) { + emitted.add(worktree.id) + } + return buildLinearFamily(linearFamily) +} + +function layoutBoundedLineage( + sorted: T[], + childrenByParent: ReadonlyMap, + childIds: ReadonlySet +): T[] { + const levels: T[][] = [] + const emitted = new Set() + const roots = sorted.filter((worktree) => !childIds.has(worktree.id)) + + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.id)) { + continue + } + const stack = [{ depth: 0, worktree: seed }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.worktree.id)) { + continue + } + emitted.add(entry.worktree.id) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.worktree) + const children = childrenByParent.get(entry.worktree.id) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].id)) { + stack.push({ depth: entry.depth + 1, worktree: children[index] }) + } + } + } + } + + const positioned: T[] = [] + let y = 0 + let previousMaxRadius = 0 + let hasPositionedRow = false + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + let maxRadius = 0 + let width = -AGENT_MAP_WORKTREE_GAP + for (const worktree of row) { + maxRadius = Math.max(maxRadius, worktree.radius) + width += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + if (hasPositionedRow) { + y += previousMaxRadius + maxRadius + LINEAGE_VERTICAL_GAP + } + let x = -width / 2 + for (const worktree of row) { + positioned.push({ ...worktree, x: x + worktree.radius, y }) + x += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + previousMaxRadius = maxRadius + hasPositionedRow = true + } + } + + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of positioned) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + return positioned + .map((worktree) => ({ ...worktree, x: worktree.x - centerX, y: worktree.y - centerY })) + .sort((a, b) => compareStable(a.id, b.id)) +} + +export function layoutAgentMapWorktreeLineage(worktrees: T[]): T[] { + const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id)) + const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree])) + const clusterChildCounts = new Map() + for (const worktree of sorted) { + if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) { + clusterChildCounts.set( + worktree.clusterParentId, + (clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1 + ) + } + } + const childrenByParent = new Map() + const childIds = new Set() + for (const worktree of sorted) { + const clusterParentId = worktree.clusterParentId + const parentId = + clusterParentId && + (clusterChildCounts.get(clusterParentId) ?? 0) <= MAX_HIERARCHICAL_CLUSTER_FANOUT + ? clusterParentId + : worktree.parentId + if (!parentId || parentId === worktree.id || !worktreesById.has(parentId)) { + continue + } + childIds.add(worktree.id) + const siblings = childrenByParent.get(parentId) + if (siblings) { + siblings.push(worktree) + } else { + childrenByParent.set(parentId, [worktree]) + } + } + if (sorted.length > MAX_EXACT_LINEAGE_WORKTREES) { + return layoutBoundedLineage(sorted, childrenByParent, childIds) + } + + const emitted = new Set() + const families: WorktreeFamily[] = [] + for (const root of sorted.filter((worktree) => !childIds.has(worktree.id))) { + if (!emitted.has(root.id)) { + families.push(buildExactFamily(root, childrenByParent, emitted)) + } + } + for (const worktree of sorted) { + if (!emitted.has(worktree.id)) { + families.push(buildExactFamily(worktree, childrenByParent, emitted)) + } + } + + return packAgentMapWorktrees(families) + .flatMap((family) => + family.worktrees.map((worktree) => ({ + ...worktree, + x: worktree.x + family.x, + y: worktree.y + family.y + })) + ) + .sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts new file mode 100644 index 00000000000..a07490dab92 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +function circles(count = 80): { id: string; x: number; y: number; radius: number }[] { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(2, '0')}`, + x: 0, + y: 0, + radius: 28 + (index % 7) * 13 + })) +} + +function measuredCircles(count = 80): { + worktrees: ReturnType + coordinateReads: () => number +} { + let reads = 0 + const worktrees = circles(count).map(({ id, radius }) => { + let x = 0 + let y = 0 + return { + id, + radius, + get x() { + reads += 1 + return x + }, + set x(value: number) { + x = value + }, + get y() { + reads += 1 + return y + }, + set y(value: number) { + y = value + } + } + }) + return { worktrees, coordinateReads: () => reads } +} + +describe('packAgentMapWorktrees', () => { + it('keeps variable-radius rings deterministic and non-overlapping', () => { + const first = packAgentMapWorktrees(circles()) + const second = packAgentMapWorktrees(circles()) + + expect(second).toEqual(first) + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('indexes rings that span multiple positive and negative grid cells', () => { + const packed = packAgentMapWorktrees( + [380, 260, 170, 145, 90].map((radius, index) => ({ + id: `large-${index}`, + x: 0, + y: 0, + radius + })) + ) + + expect(packed.some((worktree) => worktree.x < 0 || worktree.y < 0)).toBe(true) + for (const [index, worktree] of packed.entries()) { + for (const other of packed.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('keeps capped large-map packing deterministic and compact', () => { + const first = packAgentMapWorktrees(circles(300)) + const second = packAgentMapWorktrees(circles(300)) + + expect(second).toEqual(first) + expect( + Math.max(...first.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius)) + ).toBeLessThan(1_500) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds deterministic coordinate checks for larger maps', () => { + const { worktrees, coordinateReads } = measuredCircles(300) + + packAgentMapWorktrees(worktrees) + + expect(coordinateReads()).toBeLessThan(13_200_000) + }) + + it('bounds packing work for a thousand rings', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + const packed = packAgentMapWorktrees(worktrees) + const positions = packed.map(({ id, x, y, radius }) => ({ id, x, y, radius })) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + expect(packAgentMapWorktrees(circles(1_000))).toEqual(positions) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of positions.entries()) { + for (const other of positions.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds packing work when one ring dwarfs the rest', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + worktrees[0].radius = 50_000_000 + const packed = packAgentMapWorktrees(worktrees) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + }) + + it('keeps the spatial index bounded for very large rings', () => { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + const packed = packAgentMapWorktrees( + Array.from({ length: 5 }, (_, index) => ({ + id: `huge-${index}`, + x: 0, + y: 0, + radius: 50_000_000 - index * 1_000_000 + })) + ) + + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(numericMapSets).toBeLessThan(100) + } finally { + Map.prototype.set = set + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts new file mode 100644 index 00000000000..4dab5ac2346 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts @@ -0,0 +1,267 @@ +import { + addAgentMapPackingCircle, + agentMapPackingCircleOverlaps, + AGENT_MAP_PACKING_SCORE_TOLERANCE, + AGENT_MAP_WORKTREE_GAP, + type AgentMapPackableCircle, + type AgentMapPackingSpatialIndex +} from './agent-map-packing-spatial-index' + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-packing-spatial-index' + +const PACKING_ANGLE_STEPS = 72 +const MAX_PACKING_CANDIDATE_ANCHORS = 128 +const MAX_DIRECT_OVERLAP_WORKTREES = 4 +const LARGE_PACKING_THRESHOLD = 256 +const VERY_LARGE_PACKING_THRESHOLD = 512 +const SCORE_TOLERANCE = AGENT_MAP_PACKING_SCORE_TOLERANCE +const CENTER_DIRECTIONS = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1] +] as const + +type PackableWorktree = AgentMapPackableCircle + +type PackingCandidate = { + x: number + y: number + enclosingRadius: number + distanceFromCenter: number + neighborDistance?: number +} + +type PackingSearchBudget = { + angleSteps: number + candidateAnchors: number +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function hashFraction(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) / 0xffffffff +} + +function placedWorktreesOverlap( + candidate: Pick, + placed: PackableWorktree[] +): boolean { + return placed.some( + (worktree) => + Math.hypot(candidate.x - worktree.x, candidate.y - worktree.y) < + candidate.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP - SCORE_TOLERANCE + ) +} + +function comparePackingScores( + a: PackingCandidate, + b: PackingCandidate, + placed: PackableWorktree[] +): number { + for (const key of ['enclosingRadius', 'distanceFromCenter'] as const) { + if (Math.abs(a[key] - b[key]) > SCORE_TOLERANCE) { + return a[key] - b[key] + } + } + a.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(a.x - other.x, a.y - other.y), + 0 + ) + b.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(b.x - other.x, b.y - other.y), + 0 + ) + return Math.abs(a.neighborDistance - b.neighborDistance) > SCORE_TOLERANCE + ? a.neighborDistance - b.neighborDistance + : 0 +} + +function compareBoundaryAnchors(a: PackableWorktree, b: PackableWorktree): number { + return ( + Math.hypot(b.x, b.y) + b.radius - (Math.hypot(a.x, a.y) + a.radius) || compareStable(a.id, b.id) + ) +} + +function addBoundaryAnchor( + boundaryAnchors: PackableWorktree[], + worktree: PackableWorktree, + maxAnchors: number +): void { + let low = 0 + let high = boundaryAnchors.length + while (low < high) { + const middle = (low + high) >>> 1 + if (compareBoundaryAnchors(worktree, boundaryAnchors[middle]) < 0) { + high = middle + } else { + low = middle + 1 + } + } + boundaryAnchors.splice(low, 0, worktree) + if (boundaryAnchors.length > maxAnchors) { + boundaryAnchors.pop() + } +} + +function placePackedWorktree( + worktree: PackableWorktree, + placed: PackableWorktree[], + boundaryAnchors: PackableWorktree[], + spatialIndex: AgentMapPackingSpatialIndex | null, + currentRadius: number, + searchBudget: PackingSearchBudget +): void { + let best: PackingCandidate | undefined + + const anchors = placed.length <= searchBudget.candidateAnchors ? placed : boundaryAnchors + const scoreNeighbors = + searchBudget.candidateAnchors === MAX_PACKING_CANDIDATE_ANCHORS ? placed : anchors + for (const anchor of anchors) { + const orbit = anchor.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP + const angleOffset = hashFraction(`${worktree.id}:${anchor.id}`) * Math.PI * 2 + for (let step = 0; step < searchBudget.angleSteps; step += 1) { + const angle = angleOffset + (step / searchBudget.angleSteps) * Math.PI * 2 + const x = anchor.x + Math.cos(angle) * orbit + const y = anchor.y + Math.sin(angle) * orbit + const overlapCandidate = { x, y, radius: worktree.radius } + if ( + spatialIndex + ? agentMapPackingCircleOverlaps(overlapCandidate, spatialIndex) + : placedWorktreesOverlap(overlapCandidate, placed) + ) { + continue + } + const distanceFromCenter = Math.hypot(x, y) + const candidate = { + x, + y, + enclosingRadius: Math.max(currentRadius, distanceFromCenter + worktree.radius), + distanceFromCenter + } + if (!best || comparePackingScores(candidate, best, scoreNeighbors) < 0) { + best = candidate + } + } + } + + if (best) { + worktree.x = best.x + worktree.y = best.y + return + } + let fallbackX = Number.NEGATIVE_INFINITY + for (const candidate of placed) { + fallbackX = Math.max(fallbackX, candidate.x + candidate.radius) + } + worktree.x = fallbackX + worktree.radius + AGENT_MAP_WORKTREE_GAP + worktree.y = 0 +} + +function enclosingRadius(worktrees: PackableWorktree[], x: number, y: number): number { + let radius = 0 + for (const worktree of worktrees) { + radius = Math.max(radius, Math.hypot(worktree.x - x, worktree.y - y) + worktree.radius) + } + return radius +} + +function packingSearchBudget(count: number): PackingSearchBudget { + if (count > VERY_LARGE_PACKING_THRESHOLD) { + return { angleSteps: 16, candidateAnchors: 12 } + } + if (count > LARGE_PACKING_THRESHOLD) { + return { angleSteps: 24, candidateAnchors: 64 } + } + return { + angleSteps: PACKING_ANGLE_STEPS, + candidateAnchors: MAX_PACKING_CANDIDATE_ANCHORS + } +} + +function findEnclosingCenter( + worktrees: PackableWorktree[], + bounds: { left: number; right: number; top: number; bottom: number } +): { x: number; y: number } { + let x = (bounds.left + bounds.right) / 2 + let y = (bounds.top + bounds.bottom) / 2 + let radius = enclosingRadius(worktrees, x, y) + let step = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 4 + + while (step > SCORE_TOLERANCE) { + let improved = false + for (const [dx, dy] of CENTER_DIRECTIONS) { + const candidateX = x + dx * step + const candidateY = y + dy * step + const candidateRadius = enclosingRadius(worktrees, candidateX, candidateY) + if (candidateRadius < radius - SCORE_TOLERANCE) { + x = candidateX + y = candidateY + radius = candidateRadius + improved = true + } + } + if (!improved) { + step /= 2 + } + } + return { x, y } +} + +export function packAgentMapWorktrees(worktrees: T[]): T[] { + const packed = [...worktrees].sort((a, b) => b.radius - a.radius || compareStable(a.id, b.id)) + const placed: PackableWorktree[] = [] + const boundaryAnchors: PackableWorktree[] = [] + const searchBudget = packingSearchBudget(packed.length) + const spatialIndex: AgentMapPackingSpatialIndex | null = + packed.length > MAX_DIRECT_OVERLAP_WORKTREES ? new Map() : null + let currentRadius = 0 + for (const worktree of packed) { + if (placed.length > 0) { + placePackedWorktree( + worktree, + placed, + boundaryAnchors, + spatialIndex, + currentRadius, + searchBudget + ) + } + placed.push(worktree) + addBoundaryAnchor(boundaryAnchors, worktree, searchBudget.candidateAnchors) + if (spatialIndex) { + addAgentMapPackingCircle(spatialIndex, worktree) + } + currentRadius = Math.max(currentRadius, Math.hypot(worktree.x, worktree.y) + worktree.radius) + } + if (packed.length === 0) { + return packed + } + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of packed) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const center = findEnclosingCenter(packed, { left, right, top, bottom }) + for (const worktree of packed) { + worktree.x -= center.x + worktree.y -= center.y + } + return packed.sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css new file mode 100644 index 00000000000..1170931f1b8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -0,0 +1,335 @@ +.agent-map-canvas { + background-image: radial-gradient( + color-mix(in srgb, var(--muted-foreground) 16%, transparent) 0.6px, + transparent 0.6px + ); + background-size: 20px 20px; +} + +.agent-map-canvas::before { + position: absolute; + inset: 0; + background: radial-gradient( + circle at 45% 48%, + color-mix(in srgb, var(--muted) 34%, transparent), + transparent 70% + ); + content: ''; + pointer-events: none; +} + +.agent-map-project-ring { + fill: color-mix(in srgb, var(--card) 22%, transparent); + stroke: color-mix(in srgb, var(--ring) 58%, transparent); + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-label { + fill: var(--foreground); + font-family: Geist, var(--font-sans); + font-weight: 600; + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-label-frame { + overflow: visible; + pointer-events: none; +} + +.agent-map-project-label { + display: flex; + width: max-content; + max-width: calc(100% - 8px); + height: 100%; + align-items: center; + justify-content: center; + gap: 4px; + margin-inline: auto; + color: var(--foreground); + font-family: Geist, var(--font-sans); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + line-height: 1; + white-space: nowrap; +} + +.agent-map-project-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-text-stroke: 4px var(--background); + paint-order: stroke fill; +} + +.agent-map-project-count, +.agent-map-worktree-count { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-ring { + cursor: pointer; + fill: color-mix(in srgb, var(--card) 48%, transparent); + outline: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow { + fill: none; + pointer-events: none; + stroke-width: 9; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow.fleet-status-blocked { + stroke: color-mix(in srgb, var(--color-red-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-waiting { + stroke: color-mix(in srgb, var(--color-amber-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-working { + stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); +} + +.agent-map-worktree-ring:hover { + fill: color-mix(in srgb, var(--card) 72%, transparent); + stroke: var(--ring); +} + +.agent-map-worktree-ring:focus-visible { + fill: color-mix(in srgb, var(--ring) 8%, var(--card)); + outline: none; + stroke: var(--ring); + stroke-width: 2; +} + +.agent-map-worktree-ring.is-selected { + fill: color-mix(in srgb, var(--ring) 6%, var(--card)); + stroke: var(--ring); + stroke-width: 1.5; +} + +.agent-map-worktree-ring.is-open { + fill: color-mix(in srgb, var(--ring) 18%, var(--card)); + stroke: var(--ring); + stroke-width: 2.5; +} + +.agent-map-worktree-ring.is-working { + stroke: var(--color-yellow-500); +} + +.agent-map-worktree-ring.is-waiting { + stroke: var(--color-amber-500); +} + +.agent-map-worktree-ring.is-blocked { + stroke: var(--color-red-500); +} + +.agent-map-worktree-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-anchor: middle; +} + +.agent-map-worktree-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-label-group { + opacity: 0; + pointer-events: none; +} + +.agent-map-worktree-label-group.is-visible, +.agent-map-worktree-label-group.is-active { + opacity: 1; +} + +.agent-map-worktree-count { + opacity: 0; +} + +.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count { + opacity: 1; +} + +.agent-map-agent-node { + cursor: pointer; + outline: none; +} + +.agent-map-worktree-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 34%, transparent); + stroke-linecap: round; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-dasharray: 1 4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link.is-subagent { + stroke-dasharray: none; +} + +.agent-map-agent-hit { + fill: transparent; + stroke: transparent; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-mark { + fill: var(--background); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-working-glow { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--color-yellow-500) 32%, transparent); + stroke-width: 7; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-icon { + overflow: visible; + pointer-events: none; +} + +.agent-map-agent-icon > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + color: var(--foreground); +} + +.agent-map-agent-node:hover .agent-map-agent-hit, +.agent-map-agent-node:focus-visible .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 10%, transparent); + stroke: var(--ring); +} + +.agent-map-agent-node.is-selected .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 18%, transparent); + stroke: var(--ring); + stroke-width: 3; +} + +.agent-map-agent-node.is-selected .agent-map-agent-mark { + fill: color-mix(in srgb, var(--ring) 10%, var(--background)); +} + +.fleet-status-working .agent-map-agent-mark { + stroke: var(--color-yellow-500); +} + +.fleet-status-blocked .agent-map-agent-mark { + stroke: var(--color-red-500); +} + +.fleet-status-waiting .agent-map-agent-mark { + stroke: var(--color-amber-500); +} + +.fleet-status-done .agent-map-agent-mark { + stroke: var(--color-emerald-500); +} + +.fleet-status-idle .agent-map-agent-mark { + stroke: color-mix(in srgb, var(--color-neutral-500) 55%, transparent); +} + +.agent-map-agent-unread-mark { + fill: var(--color-amber-500); + pointer-events: none; + stroke: var(--background); + stroke-width: 2; +} + +.agent-map-aggregate-node circle { + fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card)); + stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-aggregate-node text { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + font-size: 11px; + font-weight: 600; + text-anchor: middle; +} + +.agent-map-legend-dot { + display: block; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--muted-foreground); + opacity: 0.42; +} + +.agent-map-legend-dot.fleet-status-working { + border: 2px solid var(--color-yellow-500); + border-top-color: transparent; + background: transparent; + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-blocked { + background: var(--color-red-500); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-waiting { + background: var(--color-amber-500); + box-shadow: 0 0 4px color-mix(in srgb, var(--color-amber-500) 72%, transparent); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-done { + background: var(--color-emerald-500); + opacity: 1; +} diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts new file mode 100644 index 00000000000..98f9ec369c5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'old task', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Repo', + worktreeName: 'Worktree', + startedAt: 100, + finishedAt: null, + stateChangedAt: 100, + statusUpdatedAt: 150, + unseen: false, + ...overrides + } +} + +function event(overrides: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: 'tab-1:leaf-1', + state: 'blocked', + prompt: 'Need a decision', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250, + interactivePrompt: '{"question":"Continue?"}', + ...overrides + } +} + +function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot { + return { generatedAt: 200, cards } +} + +describe('patchDashboardSnapshotFromAgentStatus', () => { + it('patches one known card without rebuilding the dashboard topology', () => { + const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })]) + const result = patchDashboardSnapshotFromAgentStatus(original, event()) + + expect(result.matched).toBe(true) + expect(result.snapshot.cards[0]).toMatchObject({ + bucket: 'attention', + dotState: 'blocked', + task: 'Need a decision', + lastUserMessage: 'Need a decision', + askSummary: '{"question":"Continue?"}', + stateChangedAt: 250, + statusUpdatedAt: 300, + unseen: true + }) + expect(result.snapshot.cards[1]).toBe(original.cards[1]) + }) + + it('preserves cached fields omitted from same-state hook pings', () => { + const original = snapshot([ + card({ + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + stateChangedAt: 250, + unseen: true + }) + ]) + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 }) + ) + + expect(result.snapshot.cards[0]).toMatchObject({ + task: 'old task', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + unseen: true + }) + }) + + it('ignores stale, wrong-workspace, and session-only events', () => { + const original = snapshot() + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ receivedAt: 150 })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ worktreeId: 'other' })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot + ).toBe(original) + }) + + it('asks the caller for topology only when the pane is unknown', () => { + const original = snapshot() + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ paneKey: 'tab-new:leaf-new' }) + ) + + expect(result).toEqual({ matched: false, snapshot: original }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts new file mode 100644 index 00000000000..f3d32a9b462 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -0,0 +1,83 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardSubagent, + type DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' +import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' + +export type DashboardAgentStatusPatchResult = { + matched: boolean + snapshot: DashboardSnapshot +} + +function patchedSubagents( + card: DashboardCard, + event: AgentStatusIpcPayload +): DashboardCardSubagent[] | undefined { + if (event.subagents === undefined) { + return card.subagents + } + return event.subagents.map((subagent) => ({ + id: `${card.paneKey}\u0000subagent:${subagent.id}`, + name: subagent.description || subagent.agentType || 'unknown', + dotState: subagent.state + })) +} + +/** Applies an already bounded hook event inside the pop-out renderer. */ +export function patchDashboardSnapshotFromAgentStatus( + snapshot: DashboardSnapshot, + event: AgentStatusIpcPayload +): DashboardAgentStatusPatchResult { + if (event.providerSessionOnly) { + return { matched: true, snapshot } + } + const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey) + if (index < 0) { + return { matched: false, snapshot } + } + const card = snapshot.cards[index] + if ( + event.receivedAt <= (card.statusUpdatedAt ?? 0) || + (event.worktreeId !== undefined && event.worktreeId !== card.worktreeId) + ) { + return { matched: true, snapshot } + } + + const stateChanged = event.stateStartedAt > card.stateChangedAt + const unseen = stateChanged ? card.startedAt !== 0 : card.unseen + const dotState = event.state + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const nextCard: DashboardCard = { + ...card, + ...(event.agentType ? { agentType: event.agentType } : {}), + ...(event.prompt ? { task: event.prompt, lastUserMessage: event.prompt } : {}), + ...(event.lastAssistantMessage !== undefined + ? { lastAgentMessage: event.lastAssistantMessage || undefined } + : {}), + ...(event.orchestration?.parentPaneKey + ? { parentPaneKey: event.orchestration.parentPaneKey } + : {}), + bucket, + dotState, + unseen, + stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, + statusUpdatedAt: event.receivedAt, + finishedAt: dotState === 'done' && stateChanged ? event.stateStartedAt : card.finishedAt, + askSummary: + bucket === 'attention' + ? event.interactivePrompt !== undefined + ? event.interactivePrompt || undefined + : card.askSummary + : undefined, + subagents: patchedSubagents(card, event) + } + const cards = snapshot.cards.slice() + cards[index] = nextCard + return { + matched: true, + snapshot: { ...snapshot, generatedAt: Math.max(snapshot.generatedAt, event.receivedAt), cards } + } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts new file mode 100644 index 00000000000..c44843802f8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts @@ -0,0 +1,35 @@ +import { useEffect, useState, type RefObject } from 'react' + +export type AgentMapCanvasSize = { width: number; height: number } + +export function useAgentMapCanvasSize( + containerRef: RefObject, + onResize: () => void +): AgentMapCanvasSize { + const [size, setSize] = useState({ width: 800, height: 560 }) + + useEffect(() => { + const container = containerRef.current + if (!container || typeof ResizeObserver === 'undefined') { + return + } + const measure = (): void => { + const next = container.getBoundingClientRect() + if (next.width <= 0 || next.height <= 0) { + return + } + onResize() + setSize((current) => + current.width === next.width && current.height === next.height + ? current + : { width: next.width, height: next.height } + ) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(container) + return () => observer.disconnect() + }, [containerRef, onResize]) + + return size +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx new file mode 100644 index 00000000000..a20c534e4d3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState } from 'react' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' +import { + AgentMapSnapshotWorkspaceMenu, + type AgentMapSnapshotWorkspaceMenuRequest +} from './AgentMapSnapshotWorkspaceMenu' +import { + AgentMapProjectContextMenuLoader, + type AgentMapProjectContextMenuRequest +} from './AgentMapProjectContextMenuLoader' +import { + AgentMapWorkspaceContextMenuLoader, + type AgentMapWorkspaceContextMenuRequest +} from './AgentMapWorkspaceContextMenuLoader' + +type UseAgentMapContextMenusArgs = { + /** True only where the app store lives; the pop-out gets the snapshot menu. */ + enabled: boolean + launchableAgentsByWorktreeId?: Record + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function useAgentMapContextMenus({ + enabled, + launchableAgentsByWorktreeId, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: UseAgentMapContextMenusArgs): { + contextMenus: React.JSX.Element | null + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void +} { + const requestIdRef = useRef(0) + const [workspaceRequest, setWorkspaceRequest] = + useState(null) + const [projectRequest, setProjectRequest] = useState( + null + ) + const [snapshotRequest, setSnapshotRequest] = + useState(null) + const snapshotMenuEnabled = + !enabled && (onSpawnAgent !== undefined || onSleepWorkspace !== undefined) + const openSnapshotWorkspaceMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setSnapshotRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + worktreeName: worktree.name, + launchableAgents: launchableAgentsByWorktreeId?.[worktree.worktreeId] ?? [], + clientX: event.clientX, + clientY: event.clientY + }) + }, + [launchableAgentsByWorktreeId] + ) + const openWorkspaceContextMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setProjectRequest(null) + setWorkspaceRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + executionHostId: worktree.executionHostId, + clientX: event.clientX, + clientY: event.clientY, + altKey: event.altKey + }) + }, + [] + ) + const openProjectContextMenu = useCallback( + (event: React.MouseEvent, project: AgentMapProjectRing): void => { + requestIdRef.current += 1 + setWorkspaceRequest(null) + setProjectRequest({ + id: requestIdRef.current, + projectId: project.id, + clientX: event.clientX, + clientY: event.clientY + }) + }, + [] + ) + const handleWorkspaceLifecycleComplete = useCallback((): void => { + setWorkspaceRequest(null) + }, []) + const handleProjectOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setProjectRequest(null) + } + }, + [onOpenChange] + ) + const handleSnapshotOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setSnapshotRequest(null) + } + }, + [onOpenChange] + ) + const contextMenus = snapshotMenuEnabled ? ( + snapshotRequest ? ( + + ) : null + ) : enabled ? ( + <> + {workspaceRequest ? ( + + ) : null} + {projectRequest ? ( + + ) : null} + + ) : null + + return { + contextMenus, + onOpenProjectContextMenu: enabled ? openProjectContextMenu : undefined, + onOpenWorkspaceContextMenu: enabled + ? openWorkspaceContextMenu + : snapshotMenuEnabled + ? openSnapshotWorkspaceMenu + : undefined + } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts new file mode 100644 index 00000000000..2f22f63747e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' +import type { AgentMapAgentNode } from './agent-map-layout' +import type { AgentMapViewport } from './agent-map-viewport-transition' + +type AgentMapSelectedFocusOptions = { + agents: AgentMapAgentNode[] + selectedPaneKey: string | null + viewportRef: { current: AgentMapViewport } + resolveFocusZoom: () => number + animateViewport: (from: AgentMapViewport, to: AgentMapViewport) => void + stopViewportTransition: () => void +} + +export function useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition +}: AgentMapSelectedFocusOptions): void { + const focusedAgentRef = useRef<{ + paneKey: string + x: number + y: number + zoom: number + } | null>(null) + useEffect(() => { + const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey) + if (!selectedPaneKey || !selected) { + focusedAgentRef.current = null + stopViewportTransition() + return + } + const targetZoom = resolveFocusZoom() + const focused = focusedAgentRef.current + if ( + focused?.paneKey === selectedPaneKey && + focused.x === selected.x && + focused.y === selected.y && + focused.zoom === targetZoom + ) { + return + } + focusedAgentRef.current = { + paneKey: selectedPaneKey, + x: selected.x, + y: selected.y, + zoom: targetZoom + } + animateViewport(viewportRef.current, { + center: { x: selected.x, y: selected.y }, + zoom: targetZoom + }) + }, [ + agents, + animateViewport, + resolveFocusZoom, + selectedPaneKey, + stopViewportTransition, + viewportRef + ]) +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts new file mode 100644 index 00000000000..b98bbf52afb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react' +import type { AgentMapState } from './agent-map-filter' + +const ALL_AGENT_STATES: AgentMapState[] = ['attention', 'working', 'done', 'idle'] + +/** Map-only state muting. It lives on the board rather than inside the map so + * the shared toolbar filter — the map has no rail of its own — can drive it. */ +export function useAgentMapStateFilter(): { + agentStates: ReadonlySet + toggleAgentState: (state: AgentMapState) => void + resetAgentStates: () => void +} { + const [agentStates, setAgentStates] = useState>( + () => new Set(ALL_AGENT_STATES) + ) + const toggleAgentState = useCallback((state: AgentMapState): void => { + setAgentStates((current) => { + const next = new Set(current) + if (!next.delete(state)) { + next.add(state) + } + return next + }) + }, []) + const resetAgentStates = useCallback((): void => { + setAgentStates(new Set(ALL_AGENT_STATES)) + }, []) + return { agentStates, toggleAgentState, resetAgentStates } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts new file mode 100644 index 00000000000..91a8e6e1af1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef } from 'react' +import { + startAgentMapViewportTransition, + type AgentMapViewport +} from './agent-map-viewport-transition' + +type AgentMapViewportTransitionOptions = { + durationMs: number + reducedMotion: boolean + onFrame: (viewport: AgentMapViewport) => void +} + +export function useAgentMapViewportTransition({ + durationMs, + reducedMotion, + onFrame +}: AgentMapViewportTransitionOptions): { + animate: (from: AgentMapViewport, to: AgentMapViewport) => void + stop: () => void +} { + const cancelRef = useRef<(() => void) | null>(null) + const stop = useCallback((): void => { + cancelRef.current?.() + cancelRef.current = null + }, []) + const animate = useCallback( + (from: AgentMapViewport, to: AgentMapViewport): void => { + stop() + if (reducedMotion) { + onFrame(to) + return + } + let cancel = (): void => {} + cancel = startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete: () => { + if (cancelRef.current === cancel) { + cancelRef.current = null + } + } + }) + cancelRef.current = cancel + }, + [durationMs, onFrame, reducedMotion, stop] + ) + useEffect(() => stop, [stop]) + return { animate, stop } +} diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 5d6853d848c..9a9c4e45a74 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,6 +4,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' import { useDashboardSnapshot } from './useDashboardSnapshot' ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -35,6 +39,9 @@ function snapshot(cards: DashboardCard[]): DashboardSnapshot { } let apply: (next: DashboardSnapshot) => void +let applyStatus: (next: AgentStatusIpcPayload) => void +let applyClear: (next: AgentStatusClearIpcPayload) => void +const requestSnapshot = vi.fn(async () => {}) const startViewTransition = vi.fn((cb: () => void) => { cb() return { @@ -62,7 +69,17 @@ describe('useDashboardSnapshot', () => { apply = cb return () => {} }, - requestSnapshot: vi.fn(async () => {}) + requestSnapshot + }, + agentStatus: { + onSet: (cb: (next: AgentStatusIpcPayload) => void) => { + applyStatus = cb + return () => {} + }, + onClear: (cb: (next: AgentStatusClearIpcPayload) => void) => { + applyClear = cb + return () => {} + } } } ;(document as unknown as { startViewTransition: unknown }).startViewTransition = @@ -140,4 +157,85 @@ describe('useDashboardSnapshot', () => { act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} })) expect(result.current.repoIconsByRepoId).toEqual({}) }) + + it('applies known hook status without requesting a rebuilt snapshot', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ statusUpdatedAt: 100 })]))) + requestSnapshot.mockClear() + + act(() => + applyStatus({ + paneKey: 'pk', + state: 'blocked', + prompt: 'Choose', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250 + }) + ) + + expect(requestSnapshot).not.toHaveBeenCalled() + expect(result.current.cards[0]).toMatchObject({ + bucket: 'attention', + task: 'Choose', + statusUpdatedAt: 300 + }) + }) + + it('trailing-debounces an unknown-pane burst into one topology refresh', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + act(() => { + for (let index = 0; index < 10; index += 1) { + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + vi.advanceTimersByTime(40) + } + applyClear({ paneKey: 'pk' }) + vi.advanceTimersByTime(249) + }) + + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('refreshes within the max wait while unknown events continue', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + const unknown = (index: number): void => + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + act(() => { + unknown(0) + for (let index = 1; index <= 4; index += 1) { + vi.advanceTimersByTime(200) + unknown(index) + } + }) + expect(requestSnapshot).not.toHaveBeenCalled() + + act(() => vi.advanceTimersByTime(200)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 47704958da8..28e43d54e5b 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { EMPTY_DASHBOARD_SNAPSHOT, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250 +const TOPOLOGY_REFRESH_MAX_WAIT_MS = 1_000 /** Which column each card sits in — the only thing a view transition should * animate on. Content-only updates (such as new messages) must not. */ @@ -36,8 +41,58 @@ export function useDashboardSnapshot(): DashboardSnapshot { const [snapshot, setSnapshot] = useState(EMPTY_DASHBOARD_SNAPSHOT) const columnSignatureRef = useRef('') const retainedRepoIconsRef = useRef(undefined) + const snapshotRef = useRef(snapshot) useEffect(() => { + let topologyRefreshTimer: ReturnType | null = null + let topologyRefreshStartedAt: number | null = null + let staleRefreshTimer: ReturnType | null = null + const transientClearWatermarks = new Map() + + const requestTopologyRefresh = (): void => { + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + const now = Date.now() + topologyRefreshStartedAt ??= now + const remainingMaxWait = Math.max( + 0, + TOPOLOGY_REFRESH_MAX_WAIT_MS - (now - topologyRefreshStartedAt) + ) + topologyRefreshTimer = setTimeout( + () => { + topologyRefreshTimer = null + topologyRefreshStartedAt = null + void window.api.dashboard.requestSnapshot() + }, + Math.min(TOPOLOGY_REFRESH_DEBOUNCE_MS, remainingMaxWait) + ) + } + + const scheduleStaleRefresh = (next: DashboardSnapshot): void => { + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + staleRefreshTimer = null + } + let nextDeadline = Number.POSITIVE_INFINITY + for (const card of next.cards) { + if ( + card.statusUpdatedAt !== undefined && + (card.dotState === 'working' || + card.dotState === 'blocked' || + card.dotState === 'waiting') + ) { + nextDeadline = Math.min(nextDeadline, card.statusUpdatedAt + AGENT_STATUS_STALE_AFTER_MS) + } + } + if (Number.isFinite(nextDeadline)) { + staleRefreshTimer = setTimeout( + requestTopologyRefresh, + Math.max(0, nextDeadline - Date.now() + 1) + ) + } + } + const apply = (incoming: DashboardSnapshot): void => { // The bridge omits repoIconsByRepoId on throttled republishes when it has // not changed, rather than re-sending data URLs 4x/sec. Retain the last @@ -50,6 +105,8 @@ export function useDashboardSnapshot(): DashboardSnapshot { if (next.repoIconsByRepoId !== undefined) { retainedRepoIconsRef.current = next.repoIconsByRepoId } + snapshotRef.current = next + scheduleStaleRefresh(next) const nextSignature = columnSignature(next) const layoutChanged = nextSignature !== columnSignatureRef.current columnSignatureRef.current = nextSignature @@ -72,8 +129,45 @@ export function useDashboardSnapshot(): DashboardSnapshot { } const unsubscribe = window.api.dashboard.onSnapshot(apply) + const unsubscribeStatus = window.api.agentStatus.onSet((event) => { + if ( + typeof event.connectionId === 'string' && + event.receivedAt <= (transientClearWatermarks.get(event.connectionId) ?? -1) + ) { + return + } + const result = patchDashboardSnapshotFromAgentStatus(snapshotRef.current, event) + if (!result.matched) { + if (snapshotRef.current.generatedAt !== 0) { + requestTopologyRefresh() + } + return + } + if (result.snapshot !== snapshotRef.current) { + apply(result.snapshot) + } + }) + const unsubscribeClear = window.api.agentStatus.onClear((event) => { + if ('transient' in event && event.transient) { + transientClearWatermarks.set( + event.connectionId, + Math.max(transientClearWatermarks.get(event.connectionId) ?? -1, event.clearedAt) + ) + } + requestTopologyRefresh() + }) void window.api.dashboard.requestSnapshot() - return unsubscribe + return () => { + unsubscribe() + unsubscribeStatus() + unsubscribeClear() + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + } + } }, []) return snapshot diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx new file mode 100644 index 00000000000..ea1cdb80063 --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +const mocks = vi.hoisted(() => ({ + useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })), + blockingOverlay: false, + boardProps: null as Record | null, + activateTabAndFocusPane: vi.fn() +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) + +vi.mock('./useLiveDashboardSnapshot', () => ({ + useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot +})) + +vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({ + AgentKanbanBoard: (props: Record) => { + mocks.boardProps = props + return mocks.blockingOverlay ?
: null + } +})) + +vi.mock('./AgentDashboardSettingsMenu', () => ({ + AgentDashboardSettingsMenu: () => null +})) + +vi.mock('../sidebar/use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: () => undefined +})) + +import { AgentDashboardDrawer } from './AgentDashboardDrawer' + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState( + { + agentDashboardDrawerOpen: false, + sidebarOpen: true, + sidebarWidth: 320 + }, + false + ) + mocks.useLiveDashboardSnapshot.mockClear() + mocks.blockingOverlay = false + mocks.boardProps = null + ;(window as unknown as { api: unknown }).api = { + dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) } + } +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardDrawer', () => { + it('derives no dashboard snapshot while closed', () => { + render() + + expect(mocks.useLiveDashboardSnapshot).not.toHaveBeenCalled() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.useLiveDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('leaves Escape to an open terminal panel before dismissing the drawer', () => { + mocks.blockingOverlay = true + const view = render() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(true) + + mocks.blockingOverlay = false + view.rerender() + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('hands map rendering to the dedicated popout', () => { + const openPopout = vi.mocked(window.api.dashboard.openPopout) + render() + expect(mocks.boardProps).toBeNull() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.boardProps?.initialView).toBe('board') + expect(mocks.boardProps?.workspaceContextMenusEnabled).toBeUndefined() + const onOpenMap = mocks.boardProps?.onOpenMap + expect(onOpenMap).toBeTypeOf('function') + + act(() => { + ;(onOpenMap as () => void)() + }) + + expect(openPopout).toHaveBeenCalledWith('map') + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('reveals a colliding worktree on the card execution host', () => { + const setActiveWorktree = vi.spyOn(useAppStore.getState(), 'setActiveWorktree') + render() + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + const onRevealAgent = mocks.boardProps?.onRevealAgent + expect(onRevealAgent).toBeTypeOf('function') + + act(() => { + ;( + onRevealAgent as (args: { + repoId: string + worktreeId: string + executionHostId?: string + tabId: string + leafId: string | null + }) => void + )({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + }) + + expect(setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('tab-1', 'leaf-1', { + flashFocusedPane: true + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index 927c7930346..e9e0efcc13d 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -13,6 +13,7 @@ import { WORKSPACE_TOP_CHROME_HEIGHT } from '../sidebar/workspace-chrome-metrics' import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' +import { launchDashboardAgent } from './launch-dashboard-agent' import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' import { translate } from '@/i18n/i18n' @@ -47,7 +48,7 @@ function AgentDashboardDrawerBody({ }, []) const handleRevealAgent = useCallback( (args: AgentRevealArgs) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) onClose() }, @@ -61,15 +62,23 @@ function AgentDashboardDrawerBody({ void window.api.dashboard.openPopout?.() }, [onClose]) + const handleOpenMap = useCallback(() => { + onClose() + void window.api.dashboard.openPopout?.('map') + }, [onClose]) + return ( { + it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => { + const app = source('App.tsx') + const sidebar = source('components/sidebar/index.tsx') + const nav = source('components/sidebar/SidebarNav.tsx') + + expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) + expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')") + expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/) + expect(sidebar).toContain("import('./AgentDashboardSidebarHost')") + expect(nav).not.toContain('useAgentBucketCounts') + expect(nav).not.toContain('shared/dashboard-snapshot') + expect(nav).toContain("import('./AgentDashboardSidebarEntry')") + }) + + it('keeps map computation out of the main-renderer drawer', () => { + const board = source('components/dashboard-popout/AgentKanbanBoard.tsx') + const drawer = source('components/dashboard/AgentDashboardDrawer.tsx') + + expect(board).toContain("import('./AgentDashboardMapView')") + expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/) + expect(drawer).toContain('initialView="board"') + expect(drawer).toContain("openPopout?.('map')") + expect(drawer).toContain('onOpenMap={handleOpenMap}') + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/agent-row-lineage.ts index 38efc312a67..9010c8e8ac1 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage.ts @@ -3,6 +3,7 @@ import { buildAgentRowLineageTree } from './agent-row-lineage-model' export type AgentRowLineagePresentation = { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number @@ -52,6 +53,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR // visible parent, but visual depth remains capped so dense sidebar // rows don't lose too much prompt width. depth: 1, + parentPaneKey: row.paneKey, isFirstSibling: index === 0, isLastSibling: index === children.length - 1, childCount: 0 @@ -69,3 +71,11 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR return ordered } + +export function dashboardCardParentPaneKey(row: DashboardAgentRowWithLineage): string | undefined { + const directParentPaneKey = row.entry.orchestration?.parentPaneKey + return ( + row.lineage.parentPaneKey ?? + (directParentPaneKey === row.paneKey ? undefined : directParentPaneKey) + ) +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts new file mode 100644 index 00000000000..f5db7045970 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { FolderWorkspace, ProjectGroup, TerminalTab } from '../../../../shared/types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' + +const NOW = 2_000_000_000 +const WORKSPACE_ID = folderWorkspaceKey('folder-1') +const TAB_ID = 'folder-tab' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function folderWorkspace(): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Docs workspace', + folderPath: '/workspace/docs', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Documentation', + parentPath: '/workspace', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } +} + +function tab(): TerminalTab { + return { + id: TAB_ID, + ptyId: 'pty-folder', + worktreeId: WORKSPACE_ID, + title: 'codex', + customTitle: 'Docs reviewer', + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +function entry(): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'Review the docs', + updatedAt: NOW, + stateStartedAt: NOW - 60_000, + stateHistory: [], + agentType: 'codex', + tabId: TAB_ID, + worktreeId: WORKSPACE_ID + } +} + +function state(): DashboardSnapshotState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + tabsByWorktree: { [WORKSPACE_ID]: [tab()] }, + agentStatusByPaneKey: { [PANE_KEY]: entry() }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty-folder'] }, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + settings: null + } as unknown as DashboardSnapshotState +} + +describe('buildDashboardSnapshot folder workspaces', () => { + it('places folder-workspace agents in their real project group without git assumptions', () => { + const snapshot = buildDashboardSnapshot(state(), NOW) + + expect(snapshot.cards).toHaveLength(1) + expect(snapshot.cards[0]).toMatchObject({ + paneKey: PANE_KEY, + repoId: 'folder-workspace:group-1', + repoName: 'Documentation', + worktreeId: WORKSPACE_ID, + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + expect(snapshot.filterOptions?.projects).toEqual([ + { id: 'folder-workspace:group-1', label: 'Documentation' } + ]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'folder-workspace:group-1', + worktreeId: WORKSPACE_ID, + repoName: 'Documentation', + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + ]) + }) + + it('classifies a folder workspace from its own runtime host stamp', () => { + const runtimeState = state() + runtimeState.folderWorkspaces = [ + { ...folderWorkspace(), connectionId: null, executionHostId: 'runtime:environment-1' } + ] + runtimeState.projectGroups = [{ ...projectGroup(), connectionId: null }] + + const snapshot = buildDashboardSnapshot(runtimeState, NOW) + + expect(snapshot.cards[0].hostKind).toBe('remote') + expect(snapshot.cards[0].executionHostId).toBe('runtime:environment-1') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index aa3534eac4d..90630a3d924 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' import { AGENT_STATUS_STALE_AFTER_MS, @@ -9,12 +9,47 @@ import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapsho import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab, Worktree } from '../../../../shared/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' +import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' +import type * as AgentRowLineageModule from './agent-row-lineage' + +const mapMetadataCalls = vi.hoisted(() => ({ + hostKind: vi.fn(), + parentPaneKey: vi.fn() +})) + +vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardMapWorkspaceMetadata: ( + ...args: Parameters + ) => { + mapMetadataCalls.hostKind() + return actual.dashboardCardMapWorkspaceMetadata(...args) + } + } +}) + +vi.mock('./agent-row-lineage', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardParentPaneKey: (...args: Parameters) => { + mapMetadataCalls.parentPaneKey() + return actual.dashboardCardParentPaneKey(...args) + } + } +}) const NOW = 1_000_000_000 const TAB_ID = 'tab1' const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const CHILD_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const GRANDCHILD_LEAF_ID = '44444444-4444-4444-8444-444444444444' const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const CHILD_PANE_KEY = makePaneKey(TAB_ID, CHILD_LEAF_ID) +const GRANDCHILD_PANE_KEY = makePaneKey(TAB_ID, GRANDCHILD_LEAF_ID) function entry(overrides: Partial): AgentStatusEntry { return { @@ -110,6 +145,23 @@ describe('buildDashboardSnapshot', () => { ) expect(snapshot.cards).toEqual([]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo One', + worktreeName: 'wt-one', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + }), + expect.objectContaining({ + repoId: 'r2', + worktreeId: 'w2', + repoName: 'Repo Two', + worktreeName: 'wt-two' + }) + ]) expect(snapshot.filterOptions).toEqual({ projects: [ { id: 'r1', label: 'Repo One' }, @@ -147,6 +199,124 @@ describe('buildDashboardSnapshot', () => { expect(card.unseen).toBe(true) }) + it('publishes terminal-backed orchestrated workers under their direct parent', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [CHILD_PANE_KEY]: entry({ paneKey: CHILD_PANE_KEY }), + [GRANDCHILD_PANE_KEY]: entry({ paneKey: GRANDCHILD_PANE_KEY }) + }, + runtimeAgentOrchestrationByPaneKey: { + [CHILD_PANE_KEY]: { + taskId: 'worker-task', + dispatchId: 'worker-dispatch', + parentPaneKey: PANE_KEY + }, + [GRANDCHILD_PANE_KEY]: { + taskId: 'nested-worker-task', + dispatchId: 'nested-worker-dispatch', + parentPaneKey: CHILD_PANE_KEY + } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_ID]: 'pty1', + [CHILD_LEAF_ID]: 'pty-child', + [GRANDCHILD_LEAF_ID]: 'pty-grandchild' + } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty-child', 'pty-grandchild'] } + }), + NOW + ) + + expect(snapshot.cards.map((card) => card.paneKey)).toEqual([ + PANE_KEY, + CHILD_PANE_KEY, + GRANDCHILD_PANE_KEY + ]) + expect(snapshot.cards[1]).toMatchObject({ + paneKey: CHILD_PANE_KEY, + parentPaneKey: PANE_KEY, + ptyId: 'pty-child' + }) + expect(snapshot.cards[2]).toMatchObject({ + paneKey: GRANDCHILD_PANE_KEY, + parentPaneKey: CHILD_PANE_KEY, + ptyId: 'pty-grandchild' + }) + }) + + it('preserves explicit lineage when a child runs in another worktree', () => { + const childTabId = 'child-tab' + const childPaneKey = makePaneKey(childTabId, CHILD_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + worktreesByRepo: { r1: [worktree(), worktree('w2', 'wt-two')] }, + tabsByWorktree: { w1: [tab()], w2: [tab(childTabId, 'w2')] }, + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [childPaneKey]: entry({ + paneKey: childPaneKey, + tabId: childTabId, + worktreeId: 'w2', + orchestration: { + taskId: 'child-task', + dispatchId: 'child-dispatch', + parentPaneKey: PANE_KEY + } + }) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } + }, + [childTabId]: { + root: { type: 'leaf', leafId: CHILD_LEAF_ID }, + activeLeafId: CHILD_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [CHILD_LEAF_ID]: 'pty-child' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1'], [childTabId]: ['pty-child'] } + }), + NOW + ) + + expect(snapshot.cards.find((item) => item.paneKey === childPaneKey)).toMatchObject({ + worktreeName: 'wt-two', + parentPaneKey: PANE_KEY + }) + }) + + it('does not publish malformed self-parent lineage', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ + orchestration: { + taskId: 'self-task', + dispatchId: 'self-dispatch', + parentPaneKey: PANE_KEY + } + }) + } + }), + NOW + ) + + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + }) + it('carries the tab conversation name and drops status-only titles', () => { const named = buildDashboardSnapshot( baseState({ @@ -256,16 +426,18 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].ptyId).toBeNull() }) - it('mutes unseen once the agent is acknowledged after its state change', () => { + it('moves an acknowledged completion to idle while retaining its raw done state', () => { const snapshot = buildDashboardSnapshot( baseState({ - agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + agentStatusByPaneKey: { [PANE_KEY]: entry({ state: 'done' }) }, acknowledgedAgentsByPaneKey: { [PANE_KEY]: NOW - 1000 } }), NOW ) // ack (NOW-1000) is after stateStartedAt (NOW-5000) → seen. expect(snapshot.cards[0].unseen).toBe(false) + expect(snapshot.cards[0].dotState).toBe('done') + expect(snapshot.cards[0].bucket).toBe('idle') }) it('does not mark title-derived rows unseen from synthetic timestamps', () => { @@ -346,7 +518,13 @@ describe('buildDashboardSnapshot', () => { { id: 'reviewing', label: 'Reviewing', color: 'emerald' } ], worktreesByRepo: { - r1: [{ ...worktree(), workspaceStatus: 'reviewing' }] + r1: [ + { + ...worktree(), + workspaceStatus: 'reviewing', + parentWorktreeId: 'parent-worktree' + } as Worktree & { parentWorktreeId: string } + ] }, agentStatusByPaneKey: { [PANE_KEY]: entry({ @@ -369,12 +547,16 @@ describe('buildDashboardSnapshot', () => { workspaceStatusId: 'reviewing', workspaceStatusLabel: 'Reviewing', workspaceStatusColor: 'emerald', + parentWorktreeId: 'parent-worktree', subagents: [{ name: 'Review loop', dotState: 'working' }] }) }) it('skips card-only context for count snapshots', () => { + mapMetadataCalls.hostKind.mockClear() + mapMetadataCalls.parentPaneKey.mockClear() let linkedReviewReads = 0 + let hostIdentityReads = 0 const countWorktree = worktree() Object.defineProperty(countWorktree, 'linkedPR', { enumerable: true, @@ -383,8 +565,20 @@ describe('buildDashboardSnapshot', () => { return null } }) + const countRepo = { + id: 'r1', + path: '/r1', + displayName: 'Repo One', + badgeColor: '#000', + addedAt: 0, + get connectionId() { + hostIdentityReads += 1 + return null + } + } const snapshot = buildDashboardSnapshot( baseState({ + repos: [countRepo], worktreesByRepo: { r1: [countWorktree] }, agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }), @@ -394,11 +588,19 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].workspaceStatusId).toBeUndefined() expect(snapshot.cards[0].subagents).toBeUndefined() + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + expect(snapshot.cards[0].parentWorktreeId).toBeUndefined() + expect(snapshot.cards[0].hostKind).toBeUndefined() + expect(snapshot.cards[0].workspaceKind).toBeUndefined() // Why: the card has a live pty, so only the count-path gate keeps the // host-input resolution off the sidebar's per-status-tick rebuild. expect(snapshot.cards[0].ptyId).toBe('pty1') expect(snapshot.cards[0].terminalInput).toBeUndefined() + expect(snapshot.workspaces).toBeUndefined() expect(linkedReviewReads).toBe(0) + expect(hostIdentityReads).toBe(0) + expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled() + expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled() }) it("resolves a live pty's host-input profile for card snapshots", () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index c7c3c9711cc..808ffba0236 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,25 +1,23 @@ import type { AppState } from '@/store/types' import { - DASHBOARD_MAX_LABEL_LENGTH, - type DashboardBucket, + DASHBOARD_MAX_MAP_WORKSPACES, + dashboardCardDisplayState, type DashboardCard, type DashboardCardDotState, type DashboardCardSubagent, - type DashboardSnapshot + type DashboardSnapshot, + type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDashboardCardTerminalInput, type DashboardCardTerminalInputState } from './dashboard-card-terminal-input' import { readDashboardClientHost } from './dashboard-client-host' -import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' -import { applyAgentRowLineage } from './agent-row-lineage' +import { applyAgentRowLineage, dashboardCardParentPaneKey } from './agent-row-lineage' import { lastEnteredDoneAt } from './agent-finished-timestamp' -import type { DashboardAgentRow } from './useDashboardData' import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { selectLiveAgentStatusEntriesForWorktree, @@ -41,6 +39,23 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' +import { + dashboardCardMapWorkspaceMetadata, + collectActiveDashboardWorkspaces +} from './dashboard-snapshot-workspaces' +import { + boundedLabel, + boundedLabelOrUndefined, + nonEmpty, + rowConversationName, + rowTask +} from './dashboard-card-labels' +import { + buildDashboardWorktreeLaunchOptions, + type DashboardLaunchDetectionState +} from './dashboard-worktree-launch-options' +import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' +import { dashboardBucketForDotState } from './dashboard-card-bucket' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -60,68 +75,14 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial - -function bucketForState(state: DashboardAgentRow['state']): DashboardBucket { - switch (state) { - case 'working': - return 'working' - case 'done': - return 'done' - case 'idle': - return 'idle' - // blocked | waiting — the agent needs the user. - case 'blocked': - case 'waiting': - return 'attention' - } -} - -function rowTask(row: DashboardAgentRow): string { - return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() -} - -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = (value ?? '').trim() - return trimmed.length > 0 ? trimmed : undefined -} - -/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, - * display names). Over the validator's bound the card would be dropped. */ -function boundedLabel(value: string): string { - return value.length > DASHBOARD_MAX_LABEL_LENGTH - ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) - : value -} - -function boundedLabelOrUndefined(value: string | undefined): string | undefined { - return value === undefined ? undefined : boundedLabel(value) -} - -/** Mirrors useAgentRowConversationName so the board and the sidebar label the - * same agent with the same name. */ -function rowConversationName( - row: DashboardAgentRow, - generatedTitlesEnabled: boolean -): string | undefined { - const parentPaneKey = row.entry.orchestration?.parentPaneKey - // Why: a child row rendered on its parent's tab does not own that tab's name. - if ( - row.lineage?.depth === 1 && - parentPaneKey !== undefined && - parsePaneKey(parentPaneKey)?.tabId === row.tab.id - ) { - return undefined - } - return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined -} + Partial /** * Derive the serializable dashboard snapshot from the live renderer store. * Reuses the exact per-worktree row machinery the sidebar uses * (buildWorktreeAgentRows + the indexed selectors), then flattens every - * worktree's rows into presentational cards. Subagent/child rows are excluded - * from the board (out of scope for v1). + * worktree's rows into presentational cards. Provider subagents without their + * own terminal stay folded into their spawning card. */ export function buildDashboardSnapshot( state: DashboardSnapshotState, @@ -129,40 +90,18 @@ export function buildDashboardSnapshot( options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {} ): DashboardSnapshot { const cards: DashboardCard[] = [] + const workspaces: DashboardWorkspace[] | undefined = + options.includeCardDetails === false ? undefined : [] const clientHost = readDashboardClientHost() const repoIconsByRepoId: Record = {} const includeCardDetails = options.includeCardDetails !== false const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const activeWorktrees: { - repo: AppState['repos'][number] - worktree: AppState['worktreesByRepo'][string][number] - }[] = [] - - for (const repo of state.repos ?? []) { - for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { - if (!worktree.isArchived) { - activeWorktrees.push({ repo, worktree }) - } - } - } + const showIdle = state.settings?.experimentalAgentDashboardShowIdle === true + const activeWorktrees = collectActiveDashboardWorkspaces(state, includeCardDetails) const filterOptions = options.includeFilterOptions === false ? undefined - : { - // Why: filterOptions is snapshot-level, so an over-long project label - // costs the WHOLE board, not one card. Bound it at the producer. - projects: [...new Map(activeWorktrees.map(({ repo }) => [repo.id, repo])).values()].map( - (repo) => ({ id: repo.id, label: boundedLabel(repo.displayName) }) - ), - workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0 - ? state.workspaceStatuses - : DEFAULT_WORKSPACE_STATUSES - ).map((status) => ({ - id: status.id, - label: status.label, - color: status.color - })) - } + : buildDashboardSnapshotFilterOptions(state, activeWorktrees) let singletonOrchestration: ReturnType | null = null let orchestrationByWorktree: ReturnType | null = null @@ -181,8 +120,10 @@ export function buildDashboardSnapshot( } } - for (const { repo, worktree } of activeWorktrees) { + for (const workspace of activeWorktrees) { + const { repo, worktree } = workspace const worktreeId = worktree.id + const parentWorktreeId = worktree.parentWorktreeId const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId) const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId) const entries = @@ -243,6 +184,21 @@ export function buildDashboardSnapshot( const context = includeCardDetails ? resolveDashboardCardContext(state, repo, worktree) : undefined + if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + workspaces.push({ + repoId: workspace.projectId, + worktreeId, + repoName: boundedLabel(workspace.projectName), + worktreeName: boundedLabel(worktree.displayName), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform), + workspaceStatusId: context?.workspaceStatus.id, + workspaceStatusLabel: context?.workspaceStatus.label, + workspaceStatusColor: context?.workspaceStatus.color, + hasReview: context?.hasReview, + review: context?.review + }) + } for (const row of rows) { // Child rows have no pane of their own; the board lists top-level agents. @@ -268,7 +224,10 @@ export function buildDashboardSnapshot( ? layoutPtyId : null const dotState = row.state as DashboardCardDotState - const bucket = bucketForState(row.state) + const unseen = + !isTitleDerived && + (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -286,8 +245,9 @@ export function buildDashboardSnapshot( osRelease: clientHost.osRelease }) : null + const finishedAt = lastEnteredDoneAt(row) // Only repos that actually contribute a card ship their icon. - repoIconsByRepoId[repo.id] = repo.repoIcon ?? null + repoIconsByRepoId[workspace.projectId] = workspace.repoIcon cards.push({ paneKey: row.paneKey, @@ -296,12 +256,24 @@ export function buildDashboardSnapshot( bucket, dotState, task: isTitleDerived ? '' : rowTask(row), - repoId: repo.id, + repoId: workspace.projectId, worktreeId, tabId, leafId, - repoName: boundedLabel(repo.displayName), + repoName: boundedLabel(workspace.projectName), worktreeName: boundedLabel(worktree.displayName), + ...(includeCardDetails + ? { + parentPaneKey: dashboardCardParentPaneKey(row), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata( + workspace, + ptyId, + terminalInput ?? undefined, + clientHost.platform + ) + } + : {}), workspaceStatusId: context?.workspaceStatus.id, workspaceStatusLabel: context?.workspaceStatus.label, workspaceStatusColor: context?.workspaceStatus.color, @@ -311,13 +283,12 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, - finishedAt: lastEnteredDoneAt(row), + finishedAt, stateChangedAt: row.entry.stateStartedAt || row.startedAt, + statusUpdatedAt: row.entry.updatedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. - unseen: - !isTitleDerived && - (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt, + unseen, askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined, conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled)), ...(terminalInput ? { terminalInput } : {}) @@ -328,8 +299,20 @@ export function buildDashboardSnapshot( return { generatedAt: now, cards, - showIdle: state.settings?.experimentalAgentDashboardShowIdle === true, + ...(workspaces ? { workspaces } : {}), + showIdle, filterOptions, + // Only the dashboard surfaces offer a launcher; the count-only rebuild that + // feeds the sidebar must not pay for host-detection lookups. + ...(includeCardDetails + ? { + launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions( + state, + cards, + workspaces + ) + } + : {}), repoIconsByRepoId } } diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts new file mode 100644 index 00000000000..a81129a227c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -0,0 +1,15 @@ +import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' + +export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { + switch (state) { + case 'working': + return 'working' + case 'done': + return 'done' + case 'idle': + return 'idle' + case 'blocked': + case 'waiting': + return 'attention' + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.ts b/src/renderer/src/components/dashboard/dashboard-card-context.ts index 45b17b50d53..f376a52163c 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.ts @@ -37,10 +37,10 @@ function hasLinkedReview(worktree: Worktree): boolean { function resolveReview( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardReview | undefined { - if (!state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { + if (!repo || !state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { return undefined } const branch = branchName(worktree.branch) @@ -77,7 +77,7 @@ function resolveReview( export function resolveDashboardCardContext( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardContext { const statuses = diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts new file mode 100644 index 00000000000..df4f5f99d34 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -0,0 +1,43 @@ +import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { DashboardAgentRow } from './useDashboardData' + +export function rowTask(row: DashboardAgentRow): string { + return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() +} + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = (value ?? '').trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, + * display names). Over the validator's bound the card would be dropped. */ +export function boundedLabel(value: string): string { + return value.length > DASHBOARD_MAX_LABEL_LENGTH + ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) + : value +} + +export function boundedLabelOrUndefined(value: string | undefined): string | undefined { + return value === undefined ? undefined : boundedLabel(value) +} + +/** Mirrors useAgentRowConversationName so the board and the sidebar label the + * same agent with the same name. */ +export function rowConversationName( + row: DashboardAgentRow, + generatedTitlesEnabled: boolean +): string | undefined { + const parentPaneKey = row.entry.orchestration?.parentPaneKey + // Why: a child row rendered on its parent's tab does not own that tab's name. + if ( + row.lineage?.depth === 1 && + parentPaneKey !== undefined && + parsePaneKey(parentPaneKey)?.tabId === row.tab.id + ) { + return undefined + } + return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts new file mode 100644 index 00000000000..a17776433a3 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' +import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' +import { boundedLabel } from './dashboard-card-labels' +import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' + +export function buildDashboardSnapshotFilterOptions( + state: Partial>, + activeWorkspaces: ActiveDashboardWorkspace[] +): DashboardFilterOptions { + const projects = [ + ...new Map(activeWorkspaces.map((workspace) => [workspace.projectId, workspace])).values() + ].map((workspace) => ({ + id: workspace.projectId, + label: boundedLabel(workspace.projectName) + })) + const workspaceStatuses = ( + state.workspaceStatuses && state.workspaceStatuses.length > 0 + ? state.workspaceStatuses + : DEFAULT_WORKSPACE_STATUSES + ).map((status) => ({ + id: status.id, + label: status.label, + color: status.color + })) + return { projects, workspaceStatuses } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts new file mode 100644 index 00000000000..a83619511f8 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -0,0 +1,124 @@ +import type { AppState } from '@/store/types' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardCardWorkspaceKind +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +export type ActiveDashboardWorkspace = { + projectId: string + projectName: string + repo: AppState['repos'][number] | null + repoIcon: RepoIcon | null + worktree: AppState['worktreesByRepo'][string][number] & { parentWorktreeId?: string | null } + workspaceKind: DashboardCardWorkspaceKind + remoteHostKind: Extract | null +} + +type DashboardWorkspaceState = Pick & + Partial> + +function remoteHostKind( + connectionId: string | null | undefined, + executionHostId: string | null | undefined +): ActiveDashboardWorkspace['remoteHostKind'] { + if (connectionId || executionHostId?.startsWith('ssh:')) { + return 'ssh' + } + return executionHostId && executionHostId !== 'local' ? 'remote' : null +} + +export function collectActiveDashboardWorkspaces( + state: DashboardWorkspaceState, + includeMapMetadata = true +): ActiveDashboardWorkspace[] { + const workspaces: ActiveDashboardWorkspace[] = [] + const seenWorkspaceIds = new Set() + + for (const repo of state.repos ?? []) { + for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { + if (worktree.isArchived) { + continue + } + seenWorkspaceIds.add(worktree.id) + workspaces.push({ + projectId: repo.id, + projectName: repo.displayName, + repo, + repoIcon: repo.repoIcon ?? null, + worktree, + workspaceKind: includeMapMetadata && isFolderRepo(repo) ? 'folder' : 'worktree', + remoteHostKind: includeMapMetadata + ? remoteHostKind(repo.connectionId, worktree.hostId ?? repo.executionHostId) + : null + }) + } + } + + const projectGroupsById = new Map( + (state.projectGroups ?? []).map((projectGroup) => [projectGroup.id, projectGroup]) + ) + for (const folderWorkspace of state.folderWorkspaces ?? []) { + const worktree = folderWorkspaceToWorktree(folderWorkspace) + if (folderWorkspace.isArchived || seenWorkspaceIds.has(worktree.id)) { + continue + } + const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId) + workspaces.push({ + projectId: `folder-workspace:${folderWorkspace.projectGroupId}`, + projectName: projectGroup?.name ?? folderWorkspace.name, + repo: null, + repoIcon: null, + worktree, + workspaceKind: 'folder', + remoteHostKind: includeMapMetadata + ? remoteHostKind( + folderWorkspace.connectionId ?? projectGroup?.connectionId, + worktree.hostId ?? projectGroup?.executionHostId + ) + : null + }) + } + return workspaces +} + +export function dashboardCardHostKind( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): DashboardCardHostKind { + if (workspace.remoteHostKind) { + return workspace.remoteHostKind + } + if (ptyId && parseAppSshPtyId(ptyId)) { + return 'ssh' + } + if (ptyId && getRemoteRuntimePtyEnvironmentId(ptyId)) { + return 'remote' + } + return clientPlatform === 'win32' && terminalInput?.hostPlatform === 'linux' ? 'wsl' : 'local' +} + +export function dashboardCardMapWorkspaceMetadata( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): { + hostKind: DashboardCardHostKind + executionHostId: ExecutionHostId + workspaceKind: DashboardCardWorkspaceKind +} { + return { + hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform), + executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined), + workspaceKind: workspace.workspaceKind + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts new file mode 100644 index 00000000000..e7c0c04d85c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' + +type LaunchState = Parameters[0] + +function state(overrides: Partial = {}): LaunchState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + detectedAgentIds: [], + remoteDetectedAgentIds: {}, + runtimeDetectedAgentIds: {}, + settings: null, + ...overrides + } +} + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Ship it', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + startedAt: 1, + finishedAt: null, + stateChangedAt: 1, + unseen: false, + ...overrides + } +} + +describe('buildDashboardWorktreeLaunchOptions', () => { + it('stops at the bound the snapshot validator enforces', () => { + const cards = Array.from({ length: DASHBOARD_MAX_LAUNCH_WORKTREES + 25 }, (_unused, index) => + card({ paneKey: `pane-${index}`, worktreeId: `worktree-${index}` }) + ) + + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['codex'] }), + cards + ) + + expect(Object.keys(options)).toHaveLength(DASHBOARD_MAX_LAUNCH_WORKTREES) + }) + + it('combines local detection with proven providers, honoring defaults and disabled agents', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + detectedAgentIds: ['claude', 'codex'], + settings: { + defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'] + } as LaunchState['settings'] + }), + [card(), card({ paneKey: 'pane-2', agentType: 'gemini' })] + ) + + expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] }) + }) + + it('publishes detected launch choices for workspaces without cards', () => { + const workspace: DashboardWorkspace = { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + } + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['claude', 'codex'] }), + [], + [workspace] + ) + + expect(options).toEqual({ 'empty-worktree': ['claude', 'codex'] }) + }) + + it('uses each git workspace execution host instead of local detection', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + repos: [ + { id: 'repo-ssh', connectionId: 'ssh-1' }, + { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + ] as LaunchState['repos'], + worktreesByRepo: { + 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], + 'repo-runtime': [{ id: 'runtime-worktree', repoId: 'repo-runtime' }] + } as unknown as LaunchState['worktreesByRepo'], + detectedAgentIds: ['claude'], + remoteDetectedAgentIds: { 'ssh-1': ['grok'] }, + runtimeDetectedAgentIds: { 'hub-1': ['aider'] } + }), + [ + card({ repoId: 'repo-ssh', worktreeId: 'ssh-worktree', agentType: 'grok' }), + card({ repoId: 'repo-runtime', worktreeId: 'runtime-worktree', agentType: 'aider' }) + ] + ) + + expect(options).toEqual({ 'ssh-worktree': ['grok'], 'runtime-worktree': ['aider'] }) + }) + + it('resolves folder workspace detection through its project host', () => { + const worktreeId = folderWorkspaceKey('folder-1') + const options = buildDashboardWorktreeLaunchOptions( + state({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', connectionId: 'ssh-folder' } + ] as LaunchState['folderWorkspaces'], + projectGroups: [{ id: 'group-1' }] as LaunchState['projectGroups'], + remoteDetectedAgentIds: { 'ssh-folder': ['goose'] } + }), + [card({ repoId: 'folder-workspace:group-1', worktreeId })] + ) + + expect(options).toEqual({ [worktreeId]: ['codex', 'goose'] }) + }) +}) diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts new file mode 100644 index 00000000000..16ca6c1f795 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -0,0 +1,127 @@ +import type { AppState } from '@/store/types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + DASHBOARD_MAX_LAUNCH_WORKTREES, + type DashboardCard, + type DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + filterEnabledTuiAgents, + TUI_AGENT_AUTO_PICK_ORDER +} from '../../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../../shared/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type DashboardLaunchDetectionState = Pick< + AppState, + | 'detectedAgentIds' + | 'folderWorkspaces' + | 'projectGroups' + | 'remoteDetectedAgentIds' + | 'runtimeDetectedAgentIds' +> + +type DashboardLaunchOptionState = Pick & + Partial + +type DashboardLaunchCatalog = { + foldersById: Map + groupsById: Map + reposById: Map + worktreesByRepoAndId: Map> +} + +function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): DashboardLaunchCatalog { + return { + foldersById: new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])), + groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), + reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), + worktreesByRepoAndId: new Map( + Object.entries(state.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + new Map(worktrees.map((worktree) => [worktree.id, worktree])) + ]) + ) + } +} + +function detectedAgentsForWorktree( + state: DashboardLaunchOptionState, + worktreeId: string, + repoId: string, + catalog: DashboardLaunchCatalog +): readonly TuiAgent[] { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folder = catalog.foldersById.get(workspaceScope.folderWorkspaceId) + const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined + const host = parseExecutionHostId(group?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = folder?.connectionId ?? group?.connectionId + return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] + } + + const worktree = catalog.worktreesByRepoAndId.get(repoId)?.get(worktreeId) + const repo = catalog.reposById.get(worktree?.repoId ?? repoId) + const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId + return connectionId + ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) + : (state.detectedAgentIds ?? []) +} + +/** Host-detected choices plus providers already proven to run in the workspace. */ +export function buildDashboardWorktreeLaunchOptions( + state: DashboardLaunchOptionState, + cards: readonly DashboardCard[], + workspaces: readonly DashboardWorkspace[] = [] +): Record { + const catalog = buildDashboardLaunchCatalog(state) + const cardsByWorktreeId = new Map() + const repoIdByWorktreeId = new Map( + workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId]) + ) + for (const card of cards) { + repoIdByWorktreeId.set(card.worktreeId, card.repoId) + const existing = cardsByWorktreeId.get(card.worktreeId) + if (existing) { + existing.push(card) + } else { + cardsByWorktreeId.set(card.worktreeId, [card]) + } + } + + const result: Record = {} + for (const [worktreeId, repoId] of repoIdByWorktreeId) { + // Past the validator's bound the snapshot itself would be rejected, so the + // launcher goes quiet for the tail rather than taking the board down. + if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) { + break + } + const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? [] + const available = new Set( + detectedAgentsForWorktree(state, worktreeId, repoId, catalog) + ) + for (const card of worktreeCards) { + if (isTuiAgent(card.agentType)) { + available.add(card.agentType) + } + } + const enabled = filterEnabledTuiAgents( + TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => available.has(agent)), + state.settings?.disabledTuiAgents + ) + const preferred = state.settings?.defaultTuiAgent + result[worktreeId] = + preferred && preferred !== 'blank' && enabled.includes(preferred) + ? [preferred, ...enabled.filter((agent) => agent !== preferred)] + : enabled + } + return result +} diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts new file mode 100644 index 00000000000..a207244cdb9 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnownWorktreeById: vi.fn(), + setActiveWorktree: vi.fn(), + launchAgentInNewTab: vi.fn(), + getExecutionHostIdForWorktree: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: null, + getKnownWorktreeById: mocks.getKnownWorktreeById, + setActiveWorktree: mocks.setActiveWorktree + }) + } +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree +})) + +import { launchDashboardAgent } from './launch-dashboard-agent' + +describe('launchDashboardAgent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') + mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) + mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + }) + + it('activates a folder or git workspace on its execution host before launching', () => { + expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true) + expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + agent: 'codex', + worktreeId: 'folder:docs', + launchSource: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts new file mode 100644 index 00000000000..be3b644e5a6 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -0,0 +1,23 @@ +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' + +/** Starts the requested agent through the same host-aware tab path as Quick Launch. */ +export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean { + const state = useAppStore.getState() + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const worktree = state.getKnownWorktreeById(worktreeId, executionHostId) + if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) { + return false + } + state.setActiveWorktree(worktreeId, executionHostId) + return ( + launchAgentInNewTab({ + agent, + worktreeId, + launchSource: 'unknown' + }) !== null + ) +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx new file mode 100644 index 00000000000..02f9b5a5386 --- /dev/null +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + state: { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + folderWorkspaces: [{ id: 'folder-1' }], + acknowledgedAgentsByPaneKey: {} as Record, + unrelatedEpoch: 0, + agentStatusEpoch: 0 + }, + buildDashboardSnapshot: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('./build-dashboard-snapshot', () => ({ + buildDashboardSnapshot: mocks.buildDashboardSnapshot +})) + +import { useAgentBucketCounts } from './useAgentBucketCounts' + +afterEach(() => { + cleanup() + vi.clearAllMocks() + mocks.state.acknowledgedAgentsByPaneKey = {} + mocks.state.unrelatedEpoch = 0 +}) + +describe('useAgentBucketCounts', () => { + it('includes folder workspaces in the count snapshot inputs', () => { + mocks.buildDashboardSnapshot.mockImplementation((state: { folderWorkspaces?: unknown[] }) => ({ + generatedAt: 1, + cards: state.folderWorkspaces?.length ? [{ bucket: 'working' }] : [] + })) + + const { result } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 1, done: 0, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ folderWorkspaces: mocks.state.folderWorkspaces }), + expect.any(Number), + { includeCardDetails: false, includeFilterOptions: false } + ) + }) + + it('moves acknowledged completions to idle without recomputing for unrelated writes', () => { + mocks.buildDashboardSnapshot.mockImplementation( + (state: { acknowledgedAgentsByPaneKey?: Record }) => ({ + generatedAt: 1, + cards: [ + { + bucket: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 'idle' : 'done' + } + ] + }) + ) + const { result, rerender } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 0, done: 1, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.unrelatedEpoch += 1 + rerender() + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.acknowledgedAgentsByPaneKey = { 'pane-done': 1 } + rerender() + expect(result.current).toEqual({ attention: 0, working: 0, done: 0, idle: 1 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 26b3fac9f3c..fe55b6e405c 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -25,6 +25,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch } = useAppStore( useShallow((s) => ({ @@ -38,6 +40,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId: s.terminalLayoutsByTabId, ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + folderWorkspaces: s.folderWorkspaces, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch })) ) @@ -55,8 +59,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, - // Counts do not render acknowledgement state, so avoid subscribing the sidebar to it. - acknowledgedAgentsByPaneKey: {}, + folderWorkspaces, + acknowledgedAgentsByPaneKey, // Same: counts never render a card's conversation name, so the // generated-title gate is moot and the sidebar stays off settings. settings: null @@ -86,6 +90,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch ]) } diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 65c57ab2f4e..06926f919a0 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -23,6 +23,7 @@ export type DashboardAgentRow = { startedAt: number lineage?: { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index e4d853d6e28..c632f19bc4e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -139,15 +139,37 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.onPopoutOpenChanged).toHaveBeenCalledTimes(1) expect(mocks.subscribeStore).not.toHaveBeenCalled() + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](true)) expect(mocks.subscribeStore).toHaveBeenCalledTimes(1) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) const unsubscribe = mocks.subscribeStore.mock.results[0]?.value as () => void + const notifyStore = mocks.subscribeStore.mock.calls[0][0] await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](false)) expect(unsubscribe).toHaveBeenCalledTimes(1) + const previousState = makeSnapshotWatchState() + await act(async () => notifyStore({ ...previousState, agentStatusEpoch: 1 }, previousState)) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('reveals the agent on its exact execution host', async () => { + await act(async () => root.render()) + + await act(async () => + mocks.onRevealAgent.mock.calls[0][0]({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + ) + + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') }) it('ignores unrelated store writes while retaining every snapshot input', () => { @@ -158,7 +180,6 @@ describe('useDashboardPopoutBridge', () => { 'repos', 'worktreesByRepo', 'tabsByWorktree', - 'agentStatusByPaneKey', 'retainedAgentsByPaneKey', 'migrationUnsupportedByPtyId', 'runtimeAgentOrchestrationByPaneKey', @@ -177,9 +198,15 @@ describe('useDashboardPopoutBridge', () => { key ).toBe(true) } + expect( + dashboardSnapshotInputsChanged( + { ...previousState, agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey } }, + previousState + ) + ).toBe(false) expect( dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) - ).toBe(true) + ).toBe(false) // Why: each card's preview terminal keys against a host-input profile // derived from these. Not republishing leaves the pop-out encoding bytes @@ -291,7 +318,7 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]( - { ...previousState, agentStatusEpoch: 1 }, + { ...previousState, acknowledgedAgentsByPaneKey: { pane: 1 } }, previousState ) ) @@ -300,6 +327,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]({ ...previousState }, previousState)) } + const notifyStatusChurn = (): void => { + const previousState = makeSnapshotWatchState() + act(() => + mocks.subscribeStore.mock.calls[0][0]( + { + ...previousState, + agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey }, + agentStatusEpoch: previousState.agentStatusEpoch + 1 + }, + previousState + ) + ) + } const mountAndOpen = async (): Promise => { await act(async () => root.render()) setPopoutOpen(true) @@ -330,6 +370,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { expect(mocks.publishSnapshot).toHaveBeenCalledTimes(1) }) + it('does no snapshot work during live status churn', async () => { + await mountAndOpen() + mocks.buildDashboardSnapshot.mockClear() + mocks.publishSnapshot.mockClear() + + for (let index = 0; index < 100; index += 1) { + notifyStatusChurn() + } + + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() + expect(mocks.publishSnapshot).not.toHaveBeenCalled() + }) + // A burst collapses onto the trailing timer, so that edge carries most of the // republishes this PR exists to slim down. it('omits the icon map on the throttled trailing republish', async () => { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 0c0d26909cc..2c42757036d 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,8 +1,10 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { launchDashboardAgent } from './launch-dashboard-agent' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree @@ -44,7 +46,6 @@ export function dashboardSnapshotInputsChanged( state.repos !== previousState.repos || state.worktreesByRepo !== previousState.worktreesByRepo || state.tabsByWorktree !== previousState.tabsByWorktree || - state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId || state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey || @@ -57,8 +58,11 @@ export function dashboardSnapshotInputsChanged( // Why: settings controls idle visibility and generated conversation names. state.settings !== previousState.settings || state.workspaceStatuses !== previousState.workspaceStatuses || - // Why: freshness can change a bucket without replacing any backing map. - state.agentStatusEpoch !== previousState.agentStatusEpoch || + state.detectedAgentIds !== previousState.detectedAgentIds || + state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds || + state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds || + // Live hook status is relayed straight from main to the pop-out. Rebuilding + // every card here would put map refresh work on the main renderer's hot path. // Why: each card carries the host-input profile its preview terminal keys // against, and the pop-out cannot re-derive it. Every slice that resolves // an execution host must republish or the preview keeps encoding bytes for @@ -103,12 +107,30 @@ function watchSnapshotInputs(onChanged: () => void): () => void { * the agent's worktree and focus its pane in this (main) window. */ export function useDashboardPopoutBridge(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSpawnAgent?.(launchDashboardAgent) + }, [enabled]) + + // Sleeping from the popout runs the shared teardown here, where the store and + // the live terminal panes are — the popout only names the workspace. + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSleepWorkspace?.(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + }, [enabled]) + useEffect(() => { if (!enabled) { return } return window.api.dashboard.onRevealAgent((args) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) }) }, [enabled]) diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 43933bfae62..9782f91c697 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -51,6 +51,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey) + const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) + const remoteDetectedAgentIds = useAppStore((s) => s.remoteDetectedAgentIds) + const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Why: freshness can flip a bucket without any backing map changing; the epoch // ticks on the freshness boundary so the memo re-derives stale-decayed cards. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) @@ -87,6 +90,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, // Why: read non-reactively — resolveWindowsShiftEnterEncoding takes // launch identity but never routes on it, so subscribing would only // rebuild the board. Matches the bridge's republish gate. @@ -122,6 +128,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, agentStatusEpoch ] ) diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 852f1d84337..5a767f830f4 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -138,7 +138,7 @@ describe('ExperimentalPane', () => { ) - expect(settings.experimentalAgentDashboardPopout).toBe(false) + expect(settings.experimentalAgentDashboardPopout).toBeUndefined() expect(markup).toContain('Agent Dashboard') expect(markup).toContain('Monitor agents that need you, are working, or are done') expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx new file mode 100644 index 00000000000..5eeae6f145c --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -0,0 +1,90 @@ +import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' +import { translate } from '@/i18n/i18n' + +const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { + working: 'bg-yellow-500', + done: 'bg-emerald-500', + idle: 'bg-neutral-500/50' +} + +function dashboardBucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + case 'attention': + return translate('dashboardPopout.bucket.attention', 'Needs You') + case 'working': + return translate('dashboardPopout.bucket.working', 'Working') + case 'done': + return translate('dashboardPopout.bucket.done', 'Done') + case 'idle': + return translate('dashboardPopout.bucket.idle', 'Idle') + } +} + +function DashboardBucketCounts({ + counts, + showIdle +}: { + counts: Record + showIdle: boolean +}): React.JSX.Element | null { + const active = DASHBOARD_BUCKET_ORDER.filter( + (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) + ) + if (active.length === 0) { + return null + } + return ( + + {active.map((bucket) => ( + + {bucket === 'attention' ? ( + + ) : ( + + )} + {counts[bucket]} + + ))} + + ) +} + +export default function AgentDashboardSidebarEntry(): React.JSX.Element { + const dashboardBucketCounts = useAgentBucketCounts() + const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) + const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout') + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx new file mode 100644 index 00000000000..1b90f264c3e --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import AgentDashboardSidebarHost from './AgentDashboardSidebarHost' + +vi.mock('@/components/dashboard/AgentDashboardDrawer', () => ({ + AgentDashboardDrawer: () => null +})) + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState({ agentDashboardDrawerOpen: false }, false) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardSidebarHost', () => { + it('closes the dashboard when the workspace board opens', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) + + it('closes the workspace board when the dashboard opens', async () => { + const closeWorkspaceBoard = vi.fn() + const view = render( + + ) + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + view.rerender( + + ) + + await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce()) + }) + + it('clears an open dashboard when the sidebar closes', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx new file mode 100644 index 00000000000..222571dc0be --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer' + +type AgentDashboardSidebarHostProps = { + sidebarOpen: boolean + workspaceBoardOpen: boolean + closeWorkspaceBoard: () => void + leftSidebarStyle?: React.CSSProperties + statusBarVisible: boolean +} + +/** Opt-in dashboard coordination stays outside the normal sidebar path. */ +export default function AgentDashboardSidebarHost({ + sidebarOpen, + workspaceBoardOpen, + closeWorkspaceBoard, + leftSidebarStyle, + statusBarVisible +}: AgentDashboardSidebarHostProps): React.JSX.Element | null { + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + useEffect(() => { + if (!sidebarOpen && drawerOpen) { + setDrawerOpen(false) + } + }, [drawerOpen, setDrawerOpen, sidebarOpen]) + useEffect(() => { + if (drawerOpen) { + closeWorkspaceBoard() + } + }, [closeWorkspaceBoard, drawerOpen]) + useEffect(() => { + if (workspaceBoardOpen) { + setDrawerOpen(false) + } + }, [setDrawerOpen, workspaceBoardOpen]) + + return sidebarOpen ? ( + + ) : null +} diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 36db65daeb1..6704020676b 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -219,63 +219,4 @@ describe('Sidebar', () => { expect(fetchAllWorktrees).not.toHaveBeenCalled() }) - - describe('companion board mutual exclusion', () => { - function renderWithDashboardOpen(): { - view: ReturnType - setAgentDashboardDrawerOpen: ReturnType - } { - setSidebarState(getDefaultSettings(tmpdir())) - const setAgentDashboardDrawerOpen = vi.fn() - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true, setAgentDashboardDrawerOpen } - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: false, - workspaceBoardDragPreviewOpen: false - } - return { view: render(sidebarElement()), setAgentDashboardDrawerOpen } - } - - it('keeps the agent dashboard open while a worktree drag only previews the board', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: true - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).not.toHaveBeenCalled() - }) - - it('closes the agent dashboard when the workspace board is actually opened', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false) - }) - - it('closes the workspace board when the agent dashboard opens', () => { - setSidebarState(getDefaultSettings(tmpdir())) - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - const view = render(sidebarElement()) - mocks.closeWorkspaceBoard.mockClear() - - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true } - view.rerender(sidebarElement()) - - expect(mocks.closeWorkspaceBoard).toHaveBeenCalled() - }) - }) }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 7d041bfb264..9d72ab4d489 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -2,6 +2,7 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { getDefaultSettings } from '../../../../shared/constants' @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ checkLinearConnection: vi.fn(), hasPairedMobileDevice: false, agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 }, + getAgentBucketCounts: vi.fn(), dismissMobileOnboardingBadge: vi.fn(), setSetupGuideSidebarDismissed: vi.fn() })) @@ -41,7 +43,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ })) vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({ - useAgentBucketCounts: () => mocks.agentBucketCounts + useAgentBucketCounts: () => { + mocks.getAgentBucketCounts() + return mocks.agentBucketCounts + } })) vi.mock('@/hooks/useShortcutLabel', () => ({ @@ -243,6 +248,7 @@ describe('SidebarNav', () => { const container = await renderSidebarNav() expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull() + expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled() }) it('mounts the Agent Dashboard row after opt-in', async () => { @@ -254,7 +260,8 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() - expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() + await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()) + expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1) }) it('uses a question glyph only for the Needs You count', async () => { @@ -268,6 +275,9 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() + await waitFor(() => + expect(container.querySelector('[aria-label="Needs You: 2"]')).not.toBeNull() + ) const attention = container.querySelector('[aria-label="Needs You: 2"]') const working = container.querySelector('[aria-label="Working: 3"]') const done = container.querySelector('[aria-label="Done: 1"]') diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d2a36fb79e4..b14295d3714 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,19 +1,9 @@ import React from 'react' -import { - Bell, - CalendarClock, - EyeOff, - LayoutDashboard, - MessageCircleQuestion, - Search, - Smartphone -} from 'lucide-react' +import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' -import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' -import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -25,6 +15,7 @@ import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' import { SidebarTaskNavButton } from './SidebarTaskNavButton' import { HideSidebarMenu } from './sidebar-nav-controls' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry' @@ -40,14 +31,6 @@ export function shouldShowAgentDashboardButton( return settings?.experimentalAgentDashboardPopout === true } -// Why: in-window is the default surface; only an explicit 'popout' choice opens -// the separate OS window. -function isAgentDashboardPopoutMode( - settings: Pick | null | undefined -): boolean { - return settings?.experimentalAgentDashboardMode === 'popout' -} - export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -60,93 +43,7 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } -const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { - working: 'bg-yellow-500', - done: 'bg-emerald-500', - idle: 'bg-neutral-500/50' -} - -function dashboardBucketLabel(bucket: DashboardBucket): string { - switch (bucket) { - case 'attention': - return translate('dashboardPopout.bucket.attention', 'Needs You') - case 'working': - return translate('dashboardPopout.bucket.working', 'Working') - case 'done': - return translate('dashboardPopout.bucket.done', 'Done') - case 'idle': - return translate('dashboardPopout.bucket.idle', 'Idle') - } -} - -function DashboardBucketCounts({ - counts, - showIdle -}: { - counts: Record - showIdle: boolean -}): React.JSX.Element | null { - const active = DASHBOARD_BUCKET_ORDER.filter( - (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) - ) - if (active.length === 0) { - return null - } - return ( - - {active.map((bucket) => ( - - {bucket === 'attention' ? ( - - ) : ( - - )} - {counts[bucket]} - - ))} - - ) -} - -// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so -// agent-status churn only updates this opt-in row, not the full navigation. -function AgentDashboardSidebarEntry(): React.JSX.Element { - const dashboardBucketCounts = useAgentBucketCounts() - const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) - const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings)) - const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) - const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - - return ( - - ) -} +const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry')) const SidebarNav = React.memo(function SidebarNav() { // Why: this memo boundary needs its own language subscription, while @@ -159,8 +56,13 @@ const SidebarNav = React.memo(function SidebarNav() { const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) - const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) - const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings)) + const experimentalSidebarButtons = useAppStore( + (s) => + (shouldShowAgentsButton(s.settings) ? 1 : 0) | + (shouldShowAgentDashboardButton(s.settings) ? 2 : 0) + ) + const showAgentsButton = (experimentalSidebarButtons & 1) !== 0 + const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0 const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const automationsActive = activeView === 'automations' @@ -211,7 +113,11 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} - {showAgentDashboardButton ? : null} + {showAgentDashboardButton ? ( + + + + ) : null} {showAgentsButton ? ( + )} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index daebf16e7ad..9a4dc1498f9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10289,6 +10289,11 @@ "description": "Input device used for voice dictation. System default follows the OS microphone setting.", "accessHint": "Allow microphone access to list input devices.", "allowAccess": "Allow access" + }, + "TerminalTccAttributionNotice": { + "body": "The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.", + "openManageSessions": "Open Manage Sessions", + "title": "macOS permission grants aren’t reaching terminals" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 64239245158..131be65a6df 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), killOne: () => Promise.resolve({ success: false }), - restart: () => Promise.resolve({ success: false }) + restart: () => Promise.resolve({ success: false }), + // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. + macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) } } } diff --git a/src/shared/daemon-lifecycle-telemetry.ts b/src/shared/daemon-lifecycle-telemetry.ts index 1ec13c985f6..61ecdf3f665 100644 --- a/src/shared/daemon-lifecycle-telemetry.ts +++ b/src/shared/daemon-lifecycle-telemetry.ts @@ -8,7 +8,8 @@ export const DAEMON_REPLACE_REASONS = [ 'unhealthy_resolver', 'stale_bundle', 'different_app_path', - 'failed_health_check' + 'failed_health_check', + 'severed_tcc_attribution' ] as const export type DaemonReplaceReason = (typeof DAEMON_REPLACE_REASONS)[number] diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index eba1d4a1be8..c31752cdb56 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -208,4 +208,3 @@ export function scheduleSharedControlReconnect(args: { } return { timer, reconnectAttempt: args.reconnectAttempt + 1 } } - From 39ba201ed2fd31bcfe5008ccfca2c7bc41408a61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:11:01 -0700 Subject: [PATCH 41/88] fix(source-control): prevent long branch names from overlapping chip (#12842) - `truncate` has no effect on inline boxes, so long branch names would overflow their flex item and run under the line-total chip - Adding `block` display forces text truncation with ellipsis instead - Increase gap from 1.5 to 2 so ellipsis doesn't visually merge with chip --- ...source-control-branch-context-row.test.tsx | 25 +++++++++++++++++++ .../source-control-branch-context-row.tsx | 10 +++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 0172dab5dd0..963e4e00a21 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -249,6 +249,31 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).not.toContain(' +
+ ) + return (
@@ -220,39 +262,48 @@ export function MobileHeroPairingStep({ ) : null}
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
+ + + + + {/* The row's own 18px gap moves to the Collapsible so the spacing + below stays identical whether the disclosure is open. `!` is + required: mobile-page.css is unlayered and so outranks Tailwind's + utilities layer on specificity ties. */} +
+

+ {translate( + 'auto.components.mobile.MobileHero.directAddressHint', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + )} +

+ {networkRow} +
+
+ + ) : ( + networkRow + )}
@@ -272,6 +323,7 @@ export function MobileHeroPairingStep({
diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx index e19b527fc10..3196f0e340e 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx @@ -44,6 +44,27 @@ describe('WindowsFirewallNotice', () => { expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument() }) + it('reassures on Relay that pairing works without the firewall rule', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ getWindowsFirewallStatus }) + const { rerender } = render( + + ) + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/still works over Orca Relay/i)).toBeInTheDocument() + + rerender() + expect(screen.queryByText(/still works over Orca Relay/i)).not.toBeInTheDocument() + }) + it('repairs only after explicit user action and hides after success', async () => { const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) const getWindowsFirewallStatus = vi diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx index fb9d1827673..5ac7f5caac0 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -10,12 +10,15 @@ import { cn } from '../../lib/utils' type WindowsFirewallNoticeProps = { pairingReady: boolean address?: string + /** Relay offers still carry a LAN endpoint, but a blocked one is not fatal. */ + usingRelay?: boolean className?: string } export function WindowsFirewallNotice({ pairingReady, address, + usingRelay = false, className }: WindowsFirewallNoticeProps): React.JSX.Element | null { const [status, setStatus] = useState(null) @@ -173,6 +176,14 @@ export function WindowsFirewallNotice({ { port: firewallStatus.port } )}

+ {usingRelay ? ( +

+ {translate( + 'auto.components.mobile.WindowsFirewallNotice.relay-note', + 'Pairing still works over Orca Relay — allowing this only adds the faster local connection.' + )} +

+ ) : null}
{networkIsPublic ? ( +
+ ) : null}
- { optionRefs.current['local-only'] = el }} @@ -244,64 +246,10 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.' )} />
- - {needsSignIn ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', - 'Sign in to use Orca Mobile Relay.' - )} -

- -
- ) : null} - - {relayUnavailable ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', - 'Orca Relay isn’t available in this build. Use LAN.' - )} -

- - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - -
- ) : null}
) } diff --git a/src/renderer/src/components/settings/MobilePairingPathOption.tsx b/src/renderer/src/components/settings/MobilePairingPathOption.tsx new file mode 100644 index 00000000000..191b584d2b9 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingPathOption.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +// Why: bespoke radio row instead of SettingsSegmentedControl — needs two-line +// title + description + trailing badge (STYLEGUIDE "real difference in role"). +export function MobilePairingPathOption({ + selected, + onSelect, + title, + description, + trailing, + tabIndex, + disabled = false, + positionInSet, + setSize, + optionRef +}: { + selected: boolean + onSelect: () => void + title: string + description: string + trailing?: ReactNode + tabIndex: number + disabled?: boolean + /** Stated explicitly: the Sign in panel sits between the radios in the DOM. */ + positionInSet: number + setSize: number + optionRef?: (el: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( +
{ + if (disabled) { + return + } + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + onSelect() + } + }} + className={cn( + 'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors', + // Why: match SettingsFormControls focus ring when selected uses bg-accent/40. + 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', + disabled && 'cursor-not-allowed opacity-60', + selected ? 'bg-accent/40' : 'hover:bg-accent/20' + )} + > + + {selected ? : null} + +
+
+ {title} + {trailing} +
+

{description}

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index d66a9144caf..ca7e13f5b14 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -78,12 +78,48 @@ describe('MobilePairingSetupSection', () => { expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() }) - it('explains the direct address when Orca Relay is selected', () => { - renderSection({ connectionMode: 'automatic' }) - expect(screen.getByText('This computer’s address')).toBeVisible() - expect(screen.getByRole('combobox')).toBeVisible() - expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + it('demotes this computer’s address to a disclosure when Orca Relay is selected', async () => { + const { user } = renderSection({ + connectionMode: 'automatic', + selectedAddress: undefined + }) + expect(screen.queryByText('This computer’s address')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled() + + // Relay still advertises a LAN endpoint, so the picker must stay reachable. + await user.click(screen.getByRole('button', { name: /Also use a faster local path/i })) + expect(screen.getByRole('combobox')).toBeVisible() + expect(screen.getByText(/faster than Relay/i)).toBeVisible() + }) + + it('opens the Relay address disclosure when a settings search targets it', () => { + renderSection({ + connectionMode: 'automatic', + addressDisclosureForcedOpen: true + }) + expect(screen.getByRole('combobox')).toBeVisible() + // Why: a trigger here could not collapse the pinned-open picker, so it would + // be a dead control advertising aria-expanded it does not own. + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('never hides a custom address behind the Relay disclosure', () => { + const address = 'host.example:6768' + renderSection({ + connectionMode: 'automatic', + customAddresses: [address], + selectedAddress: address, + selectedAddressIsCustom: true + }) + expect(screen.getByRole('combobox')).toHaveTextContent(address) + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('disables generate on LAN when no advertise address is selected', () => { + renderSection({ connectionMode: 'local-only', selectedAddress: undefined }) + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled() }) it('can move retry recovery into the persistent failure notice', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 8367b49fdbe..c398e199dee 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -1,7 +1,9 @@ -import type { ReactNode } from 'react' -import { Loader2, QrCode, RefreshCw } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { ChevronDown, Loader2, QrCode, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' import type { MobileNetworkInterface } from './mobile-network-interface-selection' @@ -11,6 +13,8 @@ type MobilePairingSetupSectionProps = { connectionMode: MobilePairingConnectionMode /** False when Anywhere is selected but Relay cannot be committed yet. */ canGenerate?: boolean + /** Reveals the Relay address disclosure when a settings search matches it. */ + addressDisclosureForcedOpen?: boolean connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] customAddresses: readonly string[] @@ -30,6 +34,7 @@ type MobilePairingSetupSectionProps = { export function MobilePairingSetupSection({ connectionMode, canGenerate = true, + addressDisclosureForcedOpen = false, connectionPathControl, networkInterfaces, customAddresses, @@ -46,7 +51,72 @@ export function MobilePairingSetupSection({ onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { const usingRelay = connectionMode === 'automatic' - const generateDisabled = loading || !selectedAddress || !canGenerate + const [addressDisclosureOpen, setAddressDisclosureOpen] = useState(false) + // A search hit or a custom address pins the picker open: render it outright + // rather than behind a trigger that could not collapse it anyway. + const addressDisclosurePinned = addressDisclosureForcedOpen || selectedAddressIsCustom + // Relay offers still carry a LAN endpoint for the direct fast path, but main + // substitutes its own default when the renderer has not resolved one yet. + // LAN has no such fallback, so it needs an explicit reachable host. + const generateDisabled = loading || !canGenerate || (!usingRelay && !selectedAddress) + // "Also use…" frames this as additive under Relay, not a second connection mode. + // The phone races both paths and direct wins ties when nearby. + const relayAddressLabel = translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDisclosure', + 'Also use a faster local path' + ) + + const addressControls = ( +
+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+

+ {usingRelay + ? translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' + )} +

+
+ ) return (
@@ -69,61 +139,50 @@ export function MobilePairingSetupSection({ {connectionPathControl}
-
-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.step2Title', - 'This computer’s address' - )} -

-
- - - - - - - {translate( - 'auto.components.settings.MobilePairingSetupSection.refresh', - 'Refresh network interfaces' - )} - - + {usingRelay && addressDisclosurePinned ? ( +
+

{relayAddressLabel}

+
+ {addressControls} +
-

- {usingRelay - ? translate( - 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', - 'Used for a faster direct path when nearby. Relay covers remote access.' - ) - : translate( - 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', - 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' - )} -

-
+ ) : usingRelay ? ( + // Why: Relay makes the address optional, not irrelevant — demote it to a + // disclosure so the direct fast path stays reachable without clutter. + + + + + +
+ {addressControls} +
+
+
+ ) : ( +
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.step2Title', + 'This computer’s address' + )} +

+ {addressControls} +
+ )} {showGenerateAction ? (
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 948cc96615d..1695263a740 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -23,6 +23,7 @@ type PairedDevicesProps = { type StoreState = { orcaProfileAuthStatus: { state: 'connected' | 'local' } + settingsSearchQuery: string settings: { mobileAutoRestoreFitMs: number | null mobilePairingConnectionMode?: MobilePairingConnectionMode @@ -155,7 +156,11 @@ vi.mock('./MobilePairedDevicesSection', () => ({ } })) vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) -vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ + WindowsFirewallNotice: (props: { usingRelay?: boolean }) => ( +
{String(props.usingRelay)}
+ ) +})) import { MobilePane } from './MobilePane' @@ -180,6 +185,7 @@ describe('MobilePane pairing connection mode', () => { updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings, recordFeatureInteraction: vi.fn() @@ -475,6 +481,17 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') }) + it('keeps the firewall notice on Relay, which still uses the direct LAN path', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'choose-local' })) + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('false') + }) + it('keeps the current pairing code when only custom address intent changes', async () => { const address = '100.126.117.25:6768' mocks.holder.state.settings = { @@ -777,6 +794,7 @@ describe('MobilePane', () => { mocks.updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings: mocks.updateSettings, recordFeatureInteraction: vi.fn() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 00fb7f34399..8dafdf21cef 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -24,6 +24,7 @@ import { import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference' +import { shouldOpenMobilePairingAddress } from './mobile-pane-search' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -41,6 +42,7 @@ export function MobilePane(): React.JSX.Element { const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery) const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [rotateNextQr, setRotateNextQr] = useState(false) const codeCopiedResetTimerRef = useRef(null) @@ -386,6 +388,7 @@ export function MobilePane(): React.JSX.Element { - + Promise + recordFeatureInteraction: () => void +} + +const mocks = vi.hoisted(() => { + const holder: { state: StoreState } = { state: {} as StoreState } + const useAppStore = Object.assign( + (selector: (state: StoreState) => unknown) => selector(holder.state), + { getState: () => holder.state } + ) + return { holder, useAppStore } +}) + +vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore })) +// `i18n` is read by the localized search catalogs the pane consults to decide +// whether a query targets the address picker. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en' } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('./mobile-pairing-device-polling', () => ({ useMobilePairingDevicePolling: vi.fn() })) +vi.mock('./MobilePairingSetupSection', () => ({ + MobilePairingSetupSection: (props: { addressDisclosureForcedOpen?: boolean }) => ( +
{String(props.addressDisclosureForcedOpen)}
+ ) +})) +vi.mock('./MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: () =>
+})) +vi.mock('./MobilePairingQrSection', () => ({ MobilePairingQrSection: () =>
})) +vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: () =>
})) +vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) + +import { MobilePane } from './MobilePane' + +describe('MobilePane address disclosure search wiring', () => { + beforeEach(() => { + mocks.holder.state = { + orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', + settings: { mobileAutoRestoreFitMs: null }, + updateSettings: vi.fn().mockResolvedValue(undefined), + recordFeatureInteraction: vi.fn() + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR: vi.fn(), + listDevices: vi.fn().mockResolvedValue({ devices: [] }), + listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }), + revokeDevice: vi.fn() + } + } + }) + }) + + afterEach(() => cleanup()) + + // Why: an empty query matches every catalog entry, so a missing blank guard + // would spring the disclosure open the moment Settings mounts. + it.each([ + ['', 'false'], + ['revoke', 'false'], + ['tailscale', 'true'], + ['Network Interface', 'true'] + ])('forces the disclosure open for %j: %s', (query, forced) => { + mocks.holder.state.settingsSearchQuery = query + render() + expect(screen.getByTestId('address-forced-open')).toHaveTextContent(forced) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.test.ts b/src/renderer/src/components/settings/mobile-pane-search.test.ts new file mode 100644 index 00000000000..fb909663e9f --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-search.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { getMobilePaneSearchEntries, shouldOpenMobilePairingAddress } from './mobile-pane-search' + +describe('getMobilePaneSearchEntries', () => { + // Why: the network entries were split into their own catalog and spliced back + // in. Search ranking breaks ties by index, so a reorder silently reranks rows. + it('keeps Network Interface in its original catalog position', () => { + expect(getMobilePaneSearchEntries().map((entry) => entry.title)).toEqual([ + 'Mobile Pairing', + 'Connected Devices', + 'Network Interface', + 'When you leave the mobile app' + ]) + }) +}) + +describe('shouldOpenMobilePairingAddress', () => { + it('stays closed for an empty or blank query', () => { + // Why: an empty query scores as a match for every entry, so the disclosure + // would spring open the moment Settings mounts without a search. + expect(shouldOpenMobilePairingAddress('')).toBe(false) + expect(shouldOpenMobilePairingAddress(' ')).toBe(false) + }) + + it('opens for queries that target the address picker', () => { + expect(shouldOpenMobilePairingAddress('Network Interface')).toBe(true) + expect(shouldOpenMobilePairingAddress('tailscale')).toBe(true) + expect(shouldOpenMobilePairingAddress(' LAN ')).toBe(true) + }) + + it('stays closed for queries aimed at other rows on the pane', () => { + expect(shouldOpenMobilePairingAddress('qr')).toBe(false) + expect(shouldOpenMobilePairingAddress('revoke')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.ts b/src/renderer/src/components/settings/mobile-pane-search.ts index 669a22a5ce9..576b0f86f8d 100644 --- a/src/renderer/src/components/settings/mobile-pane-search.ts +++ b/src/renderer/src/components/settings/mobile-pane-search.ts @@ -1,43 +1,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' -export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ - { - title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), - description: translate( - 'auto.components.settings.mobile.pane.search.7fb728fb2b', - 'Pair a mobile device by scanning a QR code.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') - ] - }, - { - title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), - description: translate( - 'auto.components.settings.mobile.pane.search.13419718b3', - 'Manage paired mobile devices.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.82783d9b71', - 'devices' - ), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.7d01f93ec0', - 'connected' - ) - ] - }, +const getNetworkInterfaceSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.mobile.pane.search.d96c315227', 'Network Interface'), description: translate( @@ -75,7 +41,54 @@ export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.mobile.pane.search.70f505f3c3', 'lan'), ...translateSearchKeyword('auto.components.settings.mobile.pane.search.126afc5dbd', 'remote') ] + } +]) + +/** Reveal the Relay address disclosure when a search targets the picker. */ +export function shouldOpenMobilePairingAddress(searchQuery: string): boolean { + return ( + normalizeSettingsSearchQuery(searchQuery) !== '' && + matchesSettingsSearch(searchQuery, getNetworkInterfaceSearchEntries()) + ) +} + +export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), + description: translate( + 'auto.components.settings.mobile.pane.search.7fb728fb2b', + 'Pair a mobile device by scanning a QR code.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') + ] }, + { + title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), + description: translate( + 'auto.components.settings.mobile.pane.search.13419718b3', + 'Manage paired mobile devices.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.82783d9b71', + 'devices' + ), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.7d01f93ec0', + 'connected' + ) + ] + }, + ...getNetworkInterfaceSearchEntries(), { title: translate( 'auto.components.settings.mobile.pane.search.1e711aca11', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9a4dc1498f9..a0c23b47814 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9791,13 +9791,13 @@ "unavailable": "Unavailable", "pathGroup": "How the phone reaches this computer", "anywhereTitle": "Orca Relay", - "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required.", - "signInRequired": "Sign in to use Orca Mobile Relay.", + "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required for Relay only.", + "signInRequired": "Relay only — LAN does not need an account.", "relayUnavailable": "Orca Relay isn’t available in this build. Use LAN.", - "signIn": "Sign in", - "signInAgain": "Sign in again", + "signIn": "Sign in for Relay", + "signInAgain": "Sign in again for Relay", "localTitle": "LAN", - "localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.", + "localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.", "retrying": "Retrying" }, "MobilePairingSetupSection": { @@ -9805,11 +9805,12 @@ "overview": "Generate a QR code, then scan it in Orca Mobile under Pair Desktop.", "step1Title": "Connection", "step2Title": "This computer’s address", - "step2RelayDescription": "Used for a faster direct path when nearby. Relay covers remote access.", - "step2LocalDescription": "The phone must be able to reach this address on Wi‑Fi or Tailscale.", + "step2LocalDescription": "The phone must be able to reach this address on Tailscale or Wi‑Fi.", "regenerate": "Regenerate QR code", "generate": "Generate QR code", - "refresh": "Refresh network interfaces" + "refresh": "Refresh network interfaces", + "step2RelayDescription": "Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.", + "step2RelayDisclosure": "Also use a faster local path" }, "MobileRelayBetaAvailability": { "about": "About the Orca Relay beta", @@ -12030,7 +12031,9 @@ "pairingCodeReady": "Pairing code ready", "pairThisMac": "Pair this Mac.", "pairThisPc": "Pair this PC.", - "pairThisComputer": "Pair this computer." + "pairThisComputer": "Pair this computer.", + "directAddressDisclosure": "Also use a faster local path", + "directAddressHint": "Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away." }, "MobilePage": { "e17393c6a3": "Phone preview", @@ -12177,7 +12180,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "Windows may be blocking Orca Mobile", "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", - "repair": "Repair firewall access" + "repair": "Repair firewall access", + "relay-note": "Pairing still works over Orca Relay — allowing this only adds the faster local connection." }, "MobileRelayMintFailureNotice": { "retryingTitle": "Retrying Orca Relay…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f72d53c4395..60c1f6a7a61 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9577,14 +9577,14 @@ "available": "Disponible", "reconnecting": "Reconectando", "unavailable": "No disponible", - "signIn": "Iniciar sesión", - "localDescription": "El teléfono debe estar en esta Wi‑Fi o en tu Tailscale. No requiere inicio de sesión.", + "signIn": "Iniciar sesión para Relay", + "localDescription": "El teléfono debe estar en esta Wi‑Fi o conectado por Tailscale. No se necesita cuenta.", "pathGroup": "Cómo llega el teléfono a este ordenador", "anywhereTitle": "Orca Relay", - "anywhereDescription": "El teléfono puede estar en datos móviles o cualquier Wi‑Fi. Se requiere inicio de sesión.", - "signInRequired": "Inicia sesión para usar Orca Mobile Relay.", + "anywhereDescription": "El teléfono puede estar en datos móviles o cualquier Wi‑Fi. El inicio de sesión solo es necesario para Relay.", + "signInRequired": "Solo Relay — LAN no necesita una cuenta.", "relayUnavailable": "Orca Relay no está disponible en esta compilación. Usa LAN.", - "signInAgain": "Iniciar sesión de nuevo", + "signInAgain": "Volver a iniciar sesión para Relay", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "Genera un código QR, luego escanéalo en Orca Mobile en Pair Desktop.", "step1Title": "Método de conexión", "step2Title": "Dirección de este ordenador", - "step2RelayDescription": "Se usa para una ruta directa más rápida cuando está cerca. Relay cubre el acceso remoto.", - "step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Wi‑Fi o Tailscale." + "step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Tailscale o Wi‑Fi.", + "step2RelayDescription": "Opcional. Elige la dirección Wi‑Fi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera.", + "step2RelayDisclosure": "También usar una ruta local más rápida" }, "MobileRelayBetaAvailability": { "about": "Acerca de la beta de Orca Relay", @@ -11756,7 +11757,9 @@ "stable": "Estable" }, "relayDegradedNotice": "No se pudo contactar con Relay: este código solo funciona en tu LAN o Tailscale.", - "pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile." + "pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile.", + "directAddressDisclosure": "También usar una ruta local más rápida", + "directAddressHint": "Opcional. Elige la dirección Wi‑Fi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera." }, "MobilePage": { "e17393c6a3": "Vista previa del teléfono", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "Windows may be blocking Orca Mobile", "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", - "repair": "Repair firewall access" + "repair": "Repair firewall access", + "relay-note": "El emparejamiento sigue funcionando por Orca Relay: permitirlo solo añade la conexión local más rápida." } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e331482611b..e9099533abc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9577,14 +9577,14 @@ "available": "利用可能", "reconnecting": "再接続中", "unavailable": "利用不可", - "signIn": "サインイン", - "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。サインインは不要です。", + "signIn": "Relay にサインイン", + "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。アカウントは不要です。", "pathGroup": "スマートフォンがこのコンピューターに到達する方法", "anywhereTitle": "Orca Relay", - "anywhereDescription": "スマートフォンはモバイル回線または Wi‑Fi から接続できます。サインインが必要です。", - "signInRequired": "Orca Mobile Relay を使用するにはサインインしてください。", + "anywhereDescription": "スマートフォンはモバイル回線または任意の Wi‑Fi から接続できます。サインインが必要なのは Relay のみです。", + "signInRequired": "Relay のみ — LAN にはアカウントは不要です。", "relayUnavailable": "このビルドでは Orca Relay を利用できません。LAN をご利用ください。", - "signInAgain": "再サインイン", + "signInAgain": "Relay に再サインイン", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "QR コードを生成し、Orca Mobile の Pair Desktop でスキャンしてください。", "step1Title": "接続方法", "step2Title": "このコンピューターのアドレス", - "step2RelayDescription": "近くでは高速な直接接続に使用されます。Relay はリモートアクセスに対応します。", - "step2LocalDescription": "スマートフォンが Wi‑Fi または Tailscale でこのアドレスに到達できる必要があります。" + "step2LocalDescription": "スマートフォンが Tailscale または Wi‑Fi でこのアドレスに到達できる必要があります。", + "step2RelayDescription": "任意。近く(同じ Wi‑Fi または Tailscale)にいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。", + "step2RelayDisclosure": "より速いローカル経路も使う" }, "MobileRelayBetaAvailability": { "about": "Orca Relay ベータについて", @@ -11756,7 +11757,9 @@ "stable": "安定版" }, "relayDegradedNotice": "Relay に接続できませんでした — このコードは LAN または Tailscale でのみ動作します。", - "pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。" + "pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。", + "directAddressDisclosure": "より速いローカル経路も使う", + "directAddressHint": "任意。近く(同じ Wi‑Fi または Tailscale)にいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。" }, "MobilePage": { "e17393c6a3": "スマートフォンプレビュー", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "WindowsがOrcaモバイルをブロックしている可能性があります", "blocked-description": "既存のインバウンドブロックルールがペアリング例外を上書きする可能性があります。修復はこのOrcaアプリの競合するTCPルールを削除し、プライベートネットワーク上のポート{{port}}を許可します。", - "repair": "ファイアウォールアクセスを修復" + "repair": "ファイアウォールアクセスを修復", + "relay-note": "ペアリングは Orca Relay 経由で引き続き機能します。許可すると、より高速なローカル接続が追加されるだけです。" } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 82852ed46f9..5c3ab64d74e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9577,14 +9577,14 @@ "available": "사용 가능", "reconnecting": "다시 연결 중", "unavailable": "사용할 수 없음", - "signIn": "로그인", - "localDescription": "휴대폰이 이 Wi‑Fi 또는 Tailscale에 연결되어 있어야 합니다. 로그인이 필요하지 않습니다.", + "signIn": "Relay용 로그인", + "localDescription": "휴대폰이 이 Wi‑Fi에 있거나 Tailscale로 연결되어 있어야 합니다. 계정이 필요하지 않습니다.", "pathGroup": "휴대폰이 이 컴퓨터에 연결되는 방식", "anywhereTitle": "Orca Relay", - "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결 가능합니다. 로그인이 필요합니다.", - "signInRequired": "Orca Mobile Relay를 사용하려면 로그인하세요.", + "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결할 수 있습니다. 로그인은 Relay에만 필요합니다.", + "signInRequired": "Relay만 해당 — LAN에는 계정이 필요하지 않습니다.", "relayUnavailable": "이 빌드에서는 Orca Relay를 사용할 수 없습니다. LAN을 사용하세요.", - "signInAgain": "다시 로그인", + "signInAgain": "Relay용 다시 로그인", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "QR 코드를 생성한 후 Orca Mobile의 Pair Desktop에서 스캔하세요.", "step1Title": "연결 방식", "step2Title": "이 컴퓨터의 주소", - "step2RelayDescription": "가까운 곳에서는 빠른 직접 연결에 사용됩니다. 먼 거리에서는 Relay가 원격 액세스를 제공합니다.", - "step2LocalDescription": "휴대폰이 Wi‑Fi 또는 Tailscale을 통해 이 주소에 접근할 수 있어야 합니다." + "step2LocalDescription": "휴대폰이 Tailscale 또는 Wi‑Fi를 통해 이 주소에 접근할 수 있어야 합니다.", + "step2RelayDescription": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다.", + "step2RelayDisclosure": "더 빠른 로컬 경로도 사용" }, "MobileRelayBetaAvailability": { "about": "Orca Relay 베타 정보", @@ -11756,7 +11757,9 @@ "stable": "안정 버전" }, "relayDegradedNotice": "Relay에 연결할 수 없습니다 — 이 코드는 LAN 또는 Tailscale에서만 작동합니다.", - "pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요." + "pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요.", + "directAddressDisclosure": "더 빠른 로컬 경로도 사용", + "directAddressHint": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다." }, "MobilePage": { "e17393c6a3": "휴대폰 미리보기", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows 방화벽 액세스를 확인할 수 없습니다", "blocked-title": "Windows가 Orca Mobile을 차단하고 있을 수 있습니다", "blocked-description": "기존 인바운드 차단 규칙이 페어링 예외보다 우선할 수 있습니다. 복구를 실행하면 이 Orca 앱의 충돌하는 TCP 규칙을 제거한 다음 개인 네트워크에서 포트 {{port}}을(를) 허용합니다.", - "repair": "방화벽 액세스 복구" + "repair": "방화벽 액세스 복구", + "relay-note": "페어링은 Orca Relay를 통해 계속 작동합니다. 허용하면 더 빠른 로컬 연결이 추가될 뿐입니다." } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1dacaaaee2f..6671acc8ff6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9589,14 +9589,14 @@ "available": "可用", "reconnecting": "正在重新连接", "unavailable": "不可用", - "signIn": "登录", - "localDescription": "手机必须连接此 Wi‑Fi 或您的 Tailscale。无需登录。", + "signIn": "登录以使用 Relay", + "localDescription": "手机必须连接此 Wi‑Fi 或通过 Tailscale。无需账号。", "pathGroup": "手机访问此电脑的方式", "anywhereTitle": "Orca Relay", - "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。需要登录。", - "signInRequired": "请登录以使用 Orca Mobile Relay。", + "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。仅 Relay 需要登录。", + "signInRequired": "仅 Relay 需要 — 局域网无需账号。", "relayUnavailable": "此版本不支持 Orca Relay。请使用局域网。", - "signInAgain": "重新登录", + "signInAgain": "重新登录以使用 Relay", "localTitle": "局域网" }, "MobilePairingSetupSection": { @@ -9607,8 +9607,9 @@ "overview": "生成二维码,然后在 Orca Mobile 的 Pair Desktop 中扫描。", "step1Title": "连接方式", "step2Title": "此电脑的地址", - "step2RelayDescription": "距离较近时用于快速直接连接。Relay 覆盖远程访问。", - "step2LocalDescription": "手机必须能通过 Wi‑Fi 或 Tailscale 访问此地址。" + "step2LocalDescription": "手机必须能通过 Tailscale 或 Wi‑Fi 访问此地址。", + "step2RelayDescription": "可选。选择手机在附近(同一 Wi‑Fi 或 Tailscale)时使用的地址,通常比 Relay 更快。外出时仍走 Relay。", + "step2RelayDisclosure": "也可使用更快的本地连接" }, "MobileRelayBetaAvailability": { "about": "关于 Orca Relay 测试版", @@ -11776,7 +11777,9 @@ "stable": "稳定版" }, "relayDegradedNotice": "无法连接 Relay — 此二维码仅在局域网或 Tailscale 内可用。", - "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。" + "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。", + "directAddressDisclosure": "也可使用更快的本地连接", + "directAddressHint": "可选。选择手机在附近(同一 Wi‑Fi 或 Tailscale)时使用的地址,通常比 Relay 更快。外出时仍走 Relay。" }, "MobilePage": { "e17393c6a3": "手机预览", @@ -11919,7 +11922,8 @@ "repair-unverified": "无法验证 Windows 防火墙访问权限", "blocked-title": "Windows 可能正在阻止 Orca Mobile", "blocked-description": "现有的入站阻止规则可能会覆盖配对例外。修复将移除此 Orca 应用的冲突 TCP 规则,然后在专用网络上允许端口 {{port}}。", - "repair": "修复防火墙访问权限" + "repair": "修复防火墙访问权限", + "relay-note": "配对仍可通过 Orca Relay 进行 — 允许后只是额外获得更快的本地连接。" } }, "gitlab": { From b5ad2ac16351ce614d1be843bed0ac814a017110 Mon Sep 17 00:00:00 2001 From: dcieslak19973 Date: Thu, 6 Aug 2026 03:29:19 -0500 Subject: [PATCH 45/88] fix(ai-vault): group OMP task subagent transcripts under their parent session (#12803) * fix(ai-vault): group OMP task subagent transcripts under their parent session OMP persists task-child transcripts inside the parent session's same-named artifact directory (_/), and discovery scanned them as ordinary top-level sessions - a coordinator's history drowned under its own workers. Extend the existing Claude subagent model to OMP, classifying purely by the artifact-dir layout (never by a transcript's parentSession field, which also describes non-task lineage): - prune artifact dirs from the top-level scan (name-pattern predicate) - count direct-child transcripts onto the parent row (local readdir; remote walks partition their listing instead, mirroring Claude's SSH posture) - list children on demand via the existing listSubagentSessions IPC, titled by their coordinator-given task label and linked to the layout-derived parent id - refresh the count on zero-turn cache reuse, matching Claude - extract session-scanner-roots.ts so the renderer-supplied-path allowlist for both agents lives in one module Fixes #9330 * review: harden OMP subagent classification and cover its uncovered branches Prune predicate now skips depth 0 (the workspace dir), so a workspace whose name happens to look like a session stem keeps its sessions. Drop degenerate OMP roots in ompSessionsRootDirs: OMP_CODING_AGENT_DIR='/' normalizes to '', which resolve()s to the process cwd and would have allowlisted it for the renderer-supplied subagent path. Rename session-scanner-omp-subagents.ts to -omp-subagent-transcripts.ts so it mirrors Claude's transcripts/lister split by role rather than inverting it. Correct two comments that asserted things the codebase contradicts: OMP task children do carry their own sessionId and would resume by path (OMP's own picker globs `*/*.jsonl`, so it never offers them either), and workspace dir names are not uniformly dash-prefixed. Cover branches the change added with no test: the remote/SSH partition wiring, the IPC `omp` gate and per-agent allowlist, the parse-cache zero-turn recount, and the executionHostId disk-ownership guard. Extract the remote scanner's in-memory provider into a fixtures module to stay under the max-lines cap. * review: note why child rows carry an unrendered grandchild count * review: describe the real OMP grandchild layout in the pattern comment --------- Co-authored-by: Dan Cieslak Co-authored-by: Jinwoo-H --- .../remote-session-scanner-discovery.ts | 5 +- ...mote-session-scanner-omp-subagents.test.ts | 71 +++++++++ .../remote-session-scanner-sources.ts | 12 +- .../remote-session-scanner-test-fixtures.ts | 100 ++++++++++++ .../ai-vault/remote-session-scanner-types.ts | 8 +- .../ai-vault/remote-session-scanner.test.ts | 99 +----------- .../ai-vault/session-scanner-graph-parsers.ts | 7 +- ...ssion-scanner-omp-subagent-listing.test.ts | 81 ++++++++++ .../session-scanner-omp-subagent-listing.ts | 113 +++++++++++++ ...session-scanner-omp-subagent-prune.test.ts | 149 ++++++++++++++++++ ...n-scanner-omp-subagent-transcripts.test.ts | 145 +++++++++++++++++ ...ession-scanner-omp-subagent-transcripts.ts | 119 ++++++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 22 ++- .../ai-vault/session-scanner-roots.test.ts | 24 +++ src/main/ai-vault/session-scanner-roots.ts | 64 ++++++++ .../session-scanner-source-discovery.ts | 63 +++----- .../session-scanner-subagent-transcripts.ts | 14 +- src/main/ipc/ai-vault.test.ts | 58 ++++++- src/main/ipc/ai-vault.ts | 28 ++-- 19 files changed, 1012 insertions(+), 170 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-test-fixtures.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-listing.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts create mode 100644 src/main/ai-vault/session-scanner-roots.test.ts create mode 100644 src/main/ai-vault/session-scanner-roots.ts diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index a309f51077b..91f21eab720 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -3,7 +3,6 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' @@ -25,9 +24,7 @@ export async function discoverRemoteSourceCandidates(args: { const walked = args.source.fixedChildFileSegments ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null + const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanBatches( paths, diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts new file mode 100644 index 00000000000..8185765e4d2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { + it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { + const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' + const provider = new MemoryRemoteProvider() + provider.addFile( + `${workspaceDir}/${stem}.jsonl`, + jsonLines([ + { + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/home/ada/repo', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]), + 70 + ) + for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { + provider.addFile( + `${workspaceDir}/${stem}/${label}.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'user', content: `${label} task prompt` } + } + ]), + 71 + ) + } + // A grandchild belongs to its own parent: neither a row nor a count here. + provider.addFile( + `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:02:00.000Z', + message: { role: 'user', content: 'Nested task prompt' } + } + ]), + 72 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'omp', + sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', + subagentTranscriptCount: 2, + filePath: `${workspaceDir}/${stem}.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index d704c2da671..debdc3881fc 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -12,6 +12,8 @@ import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' @@ -49,7 +51,7 @@ export function remoteSessionSources( // subagent counts instead. Partitioning also prunes the subagent // transcripts themselves, which would otherwise list as phantom // top-level sessions carrying the parent's sessionId. - collectSubagentSiblingCounts: true + partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, remoteAntigravitySource(remoteHome, hostPlatform), source( @@ -92,7 +94,13 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + { + ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + // Same posture as Claude above: OMP stores task-subagent transcripts in + // the session's same-named artifact dir; the walk supplies counts and the + // partition keeps the children out of the top-level list (#9330). + partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths + }, jsonlSource( 'droid', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-test-fixtures.ts b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts new file mode 100644 index 00000000000..c3496590168 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts @@ -0,0 +1,100 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +// Shared by the remote AI Vault scanner suites: an in-memory filesystem whose +// directory tree is derived from the file paths added to it. +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 58fb255e3cc..8c9774abe14 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { @@ -36,9 +37,10 @@ export type RemoteSessionSource = { directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. fixedChildFileSegments?: readonly string[] - // Claude layout: count `/subagents/*.jsonl` siblings from the walked - // listing and drop them from candidates instead of indexing them as sessions. - collectSubagentSiblingCounts?: boolean + // Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named + // artifact dir): count subagent transcripts from the walked listing and drop + // them from candidates instead of indexing them as sessions. + partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a4705ee1bfb..462ced9aa72 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,104 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - private readonly readDirErrors = new Map() - private readonly statErrors = new Map() - readonly readDirPaths: string[] = [] - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - failStat(path: string, error: Error): void { - this.statErrors.set(normalize(path), error) - } - - failReadDir(path: string, error: Error): void { - this.readDirErrors.set(normalize(path), error) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - this.readDirPaths.push(dir) - const readDirError = this.readDirErrors.get(dir) - if (readDirError) { - throw readDirError - } - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const statError = this.statErrors.get(normalize(filePath)) - if (statError) { - throw statError - } - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index b4d0c171171..8aeb89089fc 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { withOmpSubagentTranscriptCount } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime, ResumableSessionParseState, @@ -239,10 +240,14 @@ export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { - return accumulatorFoldResumeState( + const state = accumulatorFoldResumeState( createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), consumeMessageGraphRecordLine ) + // Why: only OMP materializes task-subagent transcripts beside its sessions + // (in the same-named artifact dir); the row UI shows the count without + // expanding details. Pi/OpenClaw have no such layout — skip the readdir. + return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } async function parseMessageGraphSessionLines(args: { diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts new file mode 100644 index 00000000000..16511e14239 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const PARENT_SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function childTranscript(id: string, timestamp: string, prompt: string): string { + return [ + JSON.stringify({ type: 'session', version: 3, id, cwd: '/repo/app', timestamp }), + JSON.stringify({ type: 'message', timestamp, message: { role: 'user', content: prompt } }) + ].join('\n') +} + +describe('listOmpSubagentSessions', () => { + it('lists artifact-dir transcripts under the parent, titled by task label', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + '2026-05-01T10:01:00.000Z', + 'Map the auth surface' + ) + ) + await writeFile( + join(artifactDir, 'BitbucketDcApi.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-333333333333', + '2026-05-01T10:02:00.000Z', + 'Read the DC API spec' + ) + ) + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), 'not a transcript') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '{}') + + const result = await listOmpSubagentSessions({ + parentFilePath: parentPath, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + // Newest first, titled by the coordinator-given task label, each linked to + // the parent derived from the layout (not the child's own session record). + expect( + result.sessions.map((session) => ({ + title: session.title, + parent: session.subagent?.parentSessionId + })) + ).toEqual([ + { title: 'BitbucketDcApi', parent: PARENT_SESSION_ID }, + { title: 'AuthAndPreflight', parent: PARENT_SESSION_ID } + ]) + }) + + it('resolves empty for a session that never delegated', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect( + listOmpSubagentSessions({ parentFilePath: parentPath, platform: 'darwin' }) + ).resolves.toEqual({ sessions: [], issues: [] }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts new file mode 100644 index 00000000000..0efcdbdc92c --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts @@ -0,0 +1,113 @@ +import { readdir, stat } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import type { + AiVaultScanIssue, + AiVaultSession, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { + isOmpSubagentTranscriptFileName, + ompArtifactDirFor +} from './session-scanner-omp-subagent-transcripts' +import { errorMessage } from './session-scanner-values' + +// Match the Claude subagent lister's deliberate parse batching: opening every +// read stream at once stalls over WSL UNC paths. +const OMP_SUBAGENT_PARSE_CONCURRENCY = 8 + +/** + * List the task-subagent transcripts of one OMP session, on demand. The main + * scan prunes session artifact directories for speed, so this is the only path + * that reads them — and only when the user expands a session's details. + * (Lives apart from session-scanner-omp-subagent-transcripts.ts so the graph + * parser can import the count decorator without a parser↔lister import cycle — + * the same split as Claude's transcripts/lister pair.) + */ +export async function listOmpSubagentSessions(args: { + parentFilePath: string + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const issues: AiVaultScanIssue[] = [] + const artifactDir = ompArtifactDirFor(args.parentFilePath) + + let entries + try { + entries = await readdir(artifactDir, { withFileTypes: true }) + } catch { + return { sessions: [], issues } + } + + const transcriptNames = entries + .filter((entry) => isOmpSubagentTranscriptFileName(entry.name, entry.isFile())) + .map((entry) => entry.name) + if (transcriptNames.length === 0) { + return { sessions: [], issues } + } + + // Why: the layout fixes the parent's sessionId (_.jsonl → the + // artifact dir); deriving it here keeps a child linking to its parent even + // when the child transcript carries its own distinct session record. + const parentSessionId = sessionIdFromFileName(args.parentFilePath) + const parsed: (AiVaultSession | null)[] = [] + for (let index = 0; index < transcriptNames.length; index += OMP_SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptNames.slice(index, index + OMP_SUBAGENT_PARSE_CONCURRENCY) + const batchResults = await Promise.all( + batch.map((name) => + parseOmpSubagentTranscript({ artifactDir, name, parentSessionId, platform, issues }) + ) + ) + parsed.push(...batchResults) + } + + return { + sessions: parsed + .filter((session): session is AiVaultSession => session !== null) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), + issues + } +} + +async function parseOmpSubagentTranscript(args: { + artifactDir: string + name: string + parentSessionId: string + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + const filePath = join(args.artifactDir, args.name) + try { + const fileStat = await stat(filePath) + // The shared OMP parser decorates every parse with an artifact-dir count, so + // a child row carries its own grandchild count. It is accurate but has no + // renderer — subagent rows don't expand — and this lister is local-only, so + // the remote partition never reaches it. + const session = await parseMessageGraphSessionFile( + 'omp', + { path: filePath, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString() }, + args.platform + ) + if (!session) { + return null + } + return { + ...session, + // Why: OMP names each child transcript after the task's label — the name + // the coordinator gave it. That beats the transcript-derived fallback + // (the raw task prompt). + title: basename(args.name, extname(args.name)), + subagent: { + parentSessionId: args.parentSessionId, + agentType: null, + // OMP parents don't record per-child terminal statuses in a shape this + // lister can attribute; unknown beats a stale guess. + status: null + } + } + } catch (err) { + args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts new file mode 100644 index 00000000000..7a8314962b7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions OMP subagent pruning', () => { + it('prunes artifact-dir transcripts instead of listing them as sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-prune-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + const artifactDir = join(workspaceDir, SESSION_STEM) + await mkdir(artifactDir, { recursive: true }) + + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]) + ) + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'session', + version: 3, + id: 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + cwd: '/repo/app', + timestamp: '2026-05-01T10:01:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Subagent task prompt' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + // Only the coordinator surfaces; the task child is not a row, but it is + // still counted for the row's "N subagents" affordance (#9330). + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: SESSION_ID, + subagentTranscriptCount: 1 + }) + }) + + it('still scans a workspace dir whose name looks like a session stem', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-depth-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Depth 0 is the workspace dir. Pruning by name alone would hide every + // session in a workspace that happened to be named like a session stem. + const workspaceDir = join(roots.ompSessionsDir, SESSION_STEM) + await mkdir(workspaceDir, { recursive: true }) + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Hello from a stem-named workspace' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.map((entry) => entry.sessionId)).toContain(SESSION_ID) + }) + + it('picks up a task transcript written after the coordinator last changed', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-late-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + await mkdir(workspaceDir, { recursive: true }) + // A coordinator that persisted no conversation turns: its file never + // changes again, so only the cached-reuse recount can see a late child. + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + } + ]) + ) + + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(first.sessions.find((entry) => entry.sessionId === SESSION_ID)).toMatchObject({ + messageCount: 0, + subagentTranscriptCount: 0 + }) + + await mkdir(join(workspaceDir, SESSION_STEM), { recursive: true }) + await writeFile( + join(workspaceDir, SESSION_STEM, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Late task prompt' } + } + ]) + ) + + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + second.sessions.find((entry) => entry.sessionId === SESSION_ID)?.subagentTranscriptCount + ).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts new file mode 100644 index 00000000000..b8c802a021f --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + OMP_SESSION_ARTIFACT_DIR_PATTERN, + countOmpSubagentTranscripts, + partitionOmpSubagentTranscriptPaths +} from './session-scanner-omp-subagent-transcripts' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeWorkspaceDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-subagents-')) + tempRoots.push(root) + return root +} + +describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => { + it('matches session artifact dir names and nothing else in the layout', () => { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(SESSION_STEM)).toBe(true) + // Task children are label-named. + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('AuthAndPreflight')).toBe(false) + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('local')).toBe(false) + // Workspace dir names OMP encodes from a cwd (relative to home, relative to + // tmp, absolute, or slug-hashed) never collide with a session stem. The + // local prune doesn't depend on this — it skips depth 0 — but the remote + // partition has no root to measure depth against, so it does. + for (const workspaceDirName of [ + `-${SESSION_STEM}`, + `-tmp-${SESSION_STEM}`, + `--${SESSION_STEM}--`, + 'home-app-85dfa2f063812c58976deb581167a634a4' + ]) { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(workspaceDirName)).toBe(false) + } + }) +}) + +describe('countOmpSubagentTranscripts', () => { + it('counts only direct-child transcripts of the artifact dir', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile(join(artifactDir, 'AuthAndPreflight.jsonl'), '') + await writeFile(join(artifactDir, 'BitbucketDcApi.jsonl'), '') + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), '') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(2) + }) + + it('returns 0 when the session never delegated (no artifact dir)', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(0) + }) +}) + +describe('partitionOmpSubagentTranscriptPaths', () => { + it('drops artifact-dir transcripts from candidates and counts them per parent', () => { + const workspace = `/home/user/.omp/agent/sessions/home-app-85dfa2f0` + const parent = `${workspace}/${SESSION_STEM}.jsonl` + const sibling = `${workspace}/2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}/${SESSION_STEM}/AuthAndPreflight.jsonl`, + `${workspace}/${SESSION_STEM}/BitbucketDcApi.jsonl`, + // A grandchild attributes to its own parent, not the top-level session — + // and never surfaces as a candidate. + `${workspace}/${SESSION_STEM}/AuthAndPreflight/Nested.jsonl`, + sibling + ]) + + expect(partition.sessionFilePaths).toEqual([parent, sibling]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(2) + expect(partition.subagentTranscriptCounts.size).toBe(1) + }) + + it('attributes a child to its nearest stamped ancestor, not the outermost one', () => { + const workspace = '/home/user/.omp/agent/sessions/home-app-85dfa2f0' + const nested = '2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111' + const partition = partitionOmpSubagentTranscriptPaths([ + `${workspace}/${SESSION_STEM}/${nested}/Grandchild.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([]) + expect([...partition.subagentTranscriptCounts]).toEqual([ + [`${workspace}/${SESSION_STEM}/${nested}.jsonl`, 1] + ]) + }) + + it('handles Windows separators', () => { + const workspace = `C:\\Users\\u\\.omp\\agent\\sessions\\home-app-85dfa2f0` + const parent = `${workspace}\\${SESSION_STEM}.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}\\${SESSION_STEM}\\AuthAndPreflight.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([parent]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(1) + }) +}) + +describe('withOmpSubagentTranscriptCount host gating', () => { + const content = JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }) + + it('never counts a local artifact dir for a remote-host transcript', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + // A local artifact dir exists at the same path, but the content came from + // an SSH host — its real task children live on that host, not on this disk. + await mkdir(join(workspace, SESSION_STEM), { recursive: true }) + await writeFile(join(workspace, SESSION_STEM, 'AuthAndPreflight.jsonl'), '') + const file = { path: parentPath, mtimeMs: 0, modifiedAt: '2026-05-01T10:00:00.000Z' } + + const remote = await parseMessageGraphSessionContent('omp', file, content, 'linux', { + executionHostId: 'ssh:host' + }) + const local = await parseMessageGraphSessionContent('omp', file, content, 'darwin') + + expect(remote?.subagentTranscriptCount).toBe(0) + expect(local?.subagentTranscriptCount).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts new file mode 100644 index 00000000000..9136e8c0d34 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -0,0 +1,119 @@ +import { readdir } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' +import type { ResumableSessionParseState } from './session-scanner-types' + +// OMP names every session `_` and stores that session's +// artifacts — including its task-subagent transcripts — in a sibling directory +// of the same name (…//_.jsonl + …/_/). +// The anchored name pattern is the classification signal: task-child +// transcripts are label-named, so they can't match. Workspace dirs are encoded +// from a cwd and can't realistically match either, but the local prune doesn't +// rely on that — it skips depth 0 outright. This deliberately reads structure +// rather than a transcript's own records: children carry their own session id +// and no parentSession field at all, so lineage is not available here (#9330). +const OMP_SESSION_DIR_NAME = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` + +// Exported so discovery prunes exactly the subtrees this module reads on +// demand — the pruning predicate and the lister can't drift apart. +export const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i') + +// Direct child of a session artifact dir: `…/_/
-
-
- -

- {translate( - 'dashboardPopout.settings.showIdleCopy', - 'Include agents that have gone quiet for 30 minutes without reporting completion. Hidden by default.' - )} -

-
- updateSettings({ experimentalAgentDashboardShowIdle: !showIdle })} - ariaLabel={translate('dashboardPopout.settings.showIdle', 'Show idle agents')} - /> -
) : null} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 5a767f830f4..e230ba7b670 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -164,29 +164,15 @@ describe('ExperimentalPane', () => { root.unmount() }) - it('exposes idle-agent visibility for pop-out dashboards', async () => { - const updateSettings = vi.fn() - const settings = { - ...getDefaultSettings('/tmp'), - experimentalAgentDashboardPopout: true - } - const { root, container } = await renderExperimentalPane({ - settings, - updateSettings - }) - const idleSwitch = container.querySelector( - '#experimental-agent-dashboard button[role="switch"][aria-label="Show idle agents"]' + it('keeps idle-agent visibility out of global settings', () => { + const markup = renderToStaticMarkup( + ) - if (!idleSwitch) { - throw new Error('Idle-agent visibility switch was not rendered') - } - await act(async () => { - idleSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardShowIdle: true }) - root.unmount() + expect(markup).not.toContain('Show idle agents') }) it('renders Cloud VM as an off-by-default experimental subsection', () => { From 18b27ac11c22f62af1071780698d6801b6c3f393 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:29:06 -0700 Subject: [PATCH 53/88] fix(terminal): show AI Vault conversation names in tabs (#12778) * fix(terminal): use provider-native session titles * refactor(terminal): source session names from AI Vault * fix(tabs): harden AI Vault title sync --- .../session-scanner-codex-cached-title.ts | 15 + ...session-scanner-parse-cache-agents.test.ts | 5 +- .../ai-vault/session-scanner-parse-cache.ts | 4 + src/main/ai-vault/session-scanner.test.ts | 1 - src/renderer/src/App.tsx | 2 + .../components/AiVaultTabTitleSyncGate.tsx | 16 + .../src/lib/ai-vault-tab-title-requests.ts | 116 ++++++ .../src/lib/ai-vault-tab-title-scan-groups.ts | 42 +++ .../src/lib/ai-vault-tab-title-sync-inputs.ts | 196 ++++++++++ .../src/lib/ai-vault-tab-title-sync.test.ts | 355 ++++++++++++++++++ .../src/lib/ai-vault-tab-title-sync.ts | 154 ++++++++ .../src/runtime/sync-runtime-graph.ts | 6 +- .../src/runtime/web-session-tabs-sync.ts | 8 + .../src/store/slices/tabs-hydration.ts | 7 + src/renderer/src/store/slices/tabs.ts | 1 + src/renderer/src/store/slices/terminals.ts | 40 ++ src/shared/ai-vault-session-title.ts | 13 + src/shared/tab-title-resolution.test.ts | 72 ++++ src/shared/tab-title-resolution.ts | 11 +- src/shared/types.ts | 5 + src/shared/workspace-session-schema.test.ts | 59 +++ src/shared/workspace-session-schema.ts | 18 + 22 files changed, 1139 insertions(+), 7 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-codex-cached-title.ts create mode 100644 src/renderer/src/components/AiVaultTabTitleSyncGate.tsx create mode 100644 src/renderer/src/lib/ai-vault-tab-title-requests.ts create mode 100644 src/renderer/src/lib/ai-vault-tab-title-scan-groups.ts create mode 100644 src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts create mode 100644 src/renderer/src/lib/ai-vault-tab-title-sync.test.ts create mode 100644 src/renderer/src/lib/ai-vault-tab-title-sync.ts create mode 100644 src/shared/ai-vault-session-title.ts diff --git a/src/main/ai-vault/session-scanner-codex-cached-title.ts b/src/main/ai-vault/session-scanner-codex-cached-title.ts new file mode 100644 index 00000000000..f356e30b890 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-cached-title.ts @@ -0,0 +1,15 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { SessionFileCandidate } from './session-scanner-types' +import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' + +export async function refreshCachedCodexTitle( + candidate: SessionFileCandidate, + session: AiVaultSession +): Promise { + const title = await readCodexSessionIndexTitle( + candidate.file.path, + candidate.codexHome, + session.sessionId + ) + return title && title !== session.title ? { ...session, title } : session +} diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index cd4977679d2..edb5b1db20a 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -183,19 +183,18 @@ describe('codex-specific resume behavior', () => { ) expect(seeded?.title).toBe('codex seed question') - // Codex names the thread lazily; the next (incremental) parse must adopt it. + // Codex names the thread lazily; an unchanged transcript must still adopt it. await writeFile( join(codexHome, 'session_index.jsonl'), `${JSON.stringify({ id: CODEX_FIXTURE_SESSION_ID, thread_name: 'Indexed thread title' })}\n` ) - await appendFile(path, `${fixture.appendLines.join('\n')}\n`) const stats = createSessionParseStats() const renamed = await parseAgentSessionFileCached( await candidateFor('codex', path, codexHome), process.platform, stats ) - expect(stats.incremental).toBe(1) + expect(stats.reused).toBe(1) expect(renamed?.title).toBe('Indexed thread title') expect(renamed).toEqual( await parseAgentSessionFile(await candidateFor('codex', path, codexHome), process.platform) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 33dd257a839..7d9068f8e39 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -13,6 +13,7 @@ import { createCursorSessionResumeState } from './session-scanner-cursor-parser' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' +import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title' // Sized past the default recency cap (1000) plus the in-scope cap (2000) so a // full steady-state result set stays resident between forced rescans. @@ -198,6 +199,9 @@ export async function parseAgentSessionFileCached( entry.session = { ...entry.session, subagentTranscriptCount } } } + if (entry.session && candidate.agent === 'codex') { + entry.session = await refreshCachedCodexTitle(candidate, entry.session) + } storeEntry(file.path, entry) return entry.session } diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index e6d49d1b965..470fbdaca07 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -165,7 +165,6 @@ describe('scanAiVaultSessions', () => { 'Indexed Codex resume picker title', 'Vault polish pass' ]) - const claude = result.sessions.find((session) => session.agent === 'claude') expect(claude).toMatchObject({ sessionId: 'claude-session', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8319d686597..294fc4a6a3f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -48,6 +48,7 @@ import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/ import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents' import RetainedAgentsSyncGate from './components/dashboard/RetainedAgentsSyncGate' import { AgentHibernationGate } from './components/AgentHibernationGate' +import { AiVaultTabTitleSyncGate } from './components/AiVaultTabTitleSyncGate' import { ActivityTitlebarControls } from './components/activity/ActivityTitlebarControls' import Sidebar from './components/Sidebar' import { shutdownBufferCaptures } from './components/terminal-pane/shutdown-buffer-captures' @@ -2247,6 +2248,7 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + {settings?.experimentalAgentDashboardPopout === true ? ( diff --git a/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx new file mode 100644 index 00000000000..9b4af4b1ac1 --- /dev/null +++ b/src/renderer/src/components/AiVaultTabTitleSyncGate.tsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import { startAiVaultTabTitleSync } from '@/lib/ai-vault-tab-title-sync' +import { useAppStore } from '@/store' + +export function AiVaultTabTitleSyncGate(): null { + useEffect( + () => + startAiVaultTabTitleSync({ + getState: useAppStore.getState, + subscribe: useAppStore.subscribe, + listSessions: (args) => window.api.aiVault.listSessions(args) + }), + [] + ) + return null +} diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts new file mode 100644 index 00000000000..e0855e13b06 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -0,0 +1,116 @@ +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { AgentType } from '../../../shared/agent-status-types' +import type { AiVaultSessionTitle } from '../../../shared/ai-vault-session-title' +import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../shared/types' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '@/store/types' + +export type AiVaultTitleRequest = { + agent: AiVaultSessionTitle['agent'] + executionHostId: ExecutionHostId + providerSession: AgentProviderSessionMetadata + refresh: boolean + scopePath: string | null + tabId: string + worktreeId: string +} + +type RequestCandidate = AiVaultTitleRequest & { priority: number } + +function tabIdFromPaneKey(paneKey: string, tabId?: string): string | null { + return tabId?.trim() || parsePaneKey(paneKey)?.tabId || null +} + +function activePaneKey(state: AppState, tabId: string): string | null { + const activeLeafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId + return activeLeafId ? `${tabId}:${activeLeafId}` : null +} + +function registerCandidate( + state: AppState, + tabsById: ReadonlyMap, + candidates: Map, + args: { + agent: AgentType | null | undefined + paneKey: string + priority: number + providerSession: AgentProviderSessionMetadata | undefined + refresh: boolean + tabId?: string + worktreeId?: string + } +): void { + if (!isAiVaultTitleAgent(args.agent) || !args.providerSession?.id) { + return + } + const tabId = tabIdFromPaneKey(args.paneKey, args.tabId) + const tab = tabId ? tabsById.get(tabId) : undefined + const worktreeId = args.worktreeId ?? tab?.worktreeId + if (!tabId || !tab || !worktreeId) { + return + } + const priority = args.priority + (activePaneKey(state, tabId) === args.paneKey ? 100 : 0) + if ((candidates.get(tabId)?.priority ?? -1) >= priority) { + return + } + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const scopePath = state.getKnownWorktreeById(worktreeId, executionHostId)?.path?.trim() || null + candidates.set(tabId, { + agent: args.agent, + executionHostId, + providerSession: args.providerSession, + refresh: args.refresh, + scopePath, + tabId, + worktreeId, + priority + }) +} + +export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] { + const tabsById = new Map( + Object.values(state.tabsByWorktree) + .flat() + .map((tab) => [tab.id, tab] as const) + ) + const candidates = new Map() + + for (const entry of Object.values(state.retainedAgentsByPaneKey)) { + registerCandidate(state, tabsById, candidates, { + agent: entry.agentType, + paneKey: entry.entry.paneKey, + priority: 10, + providerSession: entry.entry.providerSession, + refresh: false, + tabId: entry.entry.tabId, + worktreeId: entry.worktreeId + }) + } + for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) { + registerCandidate(state, tabsById, candidates, { + agent: record.agent, + paneKey: record.paneKey, + priority: 20, + providerSession: record.providerSession, + refresh: false, + tabId: record.tabId, + worktreeId: record.worktreeId + }) + } + for (const entry of Object.values(state.agentStatusByPaneKey)) { + registerCandidate(state, tabsById, candidates, { + agent: entry.agentType, + paneKey: entry.paneKey, + priority: 30, + providerSession: entry.providerSession, + refresh: true, + tabId: entry.tabId, + worktreeId: entry.worktreeId + }) + } + + return [...candidates.values()].map(({ priority: _priority, ...request }) => request) +} diff --git a/src/renderer/src/lib/ai-vault-tab-title-scan-groups.ts b/src/renderer/src/lib/ai-vault-tab-title-scan-groups.ts new file mode 100644 index 00000000000..759098972c0 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-scan-groups.ts @@ -0,0 +1,42 @@ +import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AiVaultTitleRequest } from './ai-vault-tab-title-requests' + +export function groupAiVaultTitleRequests( + requests: AiVaultTitleRequest[] +): AiVaultTitleRequest[][] { + const byHost = new Map>() + for (const request of requests) { + const byPath = byHost.get(request.executionHostId) ?? new Map() + const pathRequests = byPath.get(request.scopePath) + if (pathRequests) { + pathRequests.push(request) + } else { + byPath.set(request.scopePath, [request]) + } + byHost.set(request.executionHostId, byPath) + } + + const groups: AiVaultTitleRequest[][] = [] + for (const byPath of byHost.values()) { + const unscoped = byPath.get(null) ?? [] + const scoped = [...byPath].filter( + (entry): entry is [string, AiVaultTitleRequest[]] => entry[0] !== null + ) + if (scoped.length === 0) { + if (unscoped.length > 0) { + groups.push(unscoped) + } + continue + } + for (let index = 0; index < scoped.length; index += AI_VAULT_SCOPE_PATHS_MAX_COUNT) { + groups.push([ + ...(index === 0 ? unscoped : []), + ...scoped + .slice(index, index + AI_VAULT_SCOPE_PATHS_MAX_COUNT) + .flatMap(([, pathRequests]) => pathRequests) + ]) + } + } + return groups +} diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts new file mode 100644 index 00000000000..a2e51b82899 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-sync-inputs.ts @@ -0,0 +1,196 @@ +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title' +import type { TerminalTab } from '../../../shared/types' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { AppState } from '@/store/types' +import { collectAiVaultTitleRequests } from './ai-vault-tab-title-requests' + +function providerSessionEqual( + left: AgentProviderSessionMetadata | undefined, + right: AgentProviderSessionMetadata | undefined +): boolean { + return left?.key === right?.key && left?.id === right?.id +} + +function relevantRecordEqual( + current: Record, + previous: Record, + relevant: (entry: T) => boolean, + equal: (current: T, previous: T) => boolean +): boolean { + let currentCount = 0 + let previousCount = 0 + for (const [key, entry] of Object.entries(current)) { + if (!relevant(entry)) { + continue + } + currentCount++ + const previousEntry = previous[key] + if (!previousEntry || !relevant(previousEntry) || !equal(entry, previousEntry)) { + return false + } + } + for (const entry of Object.values(previous)) { + if (relevant(entry)) { + previousCount++ + } + } + return currentCount === previousCount +} + +function agentRecordsEqual(current: AppState, previous: AppState): boolean { + const relevantStatus = (entry: AppState['agentStatusByPaneKey'][string]): boolean => + isAiVaultTitleAgent(entry.agentType) && Boolean(entry.providerSession?.id) + const statusEqual = relevantRecordEqual( + current.agentStatusByPaneKey, + previous.agentStatusByPaneKey, + relevantStatus, + (left, right) => + left.agentType === right.agentType && + left.paneKey === right.paneKey && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId && + providerSessionEqual(left.providerSession, right.providerSession) + ) + if (!statusEqual) { + return false + } + + const relevantRetained = (entry: AppState['retainedAgentsByPaneKey'][string]): boolean => + isAiVaultTitleAgent(entry.agentType) && Boolean(entry.entry.providerSession?.id) + const retainedEqual = relevantRecordEqual( + current.retainedAgentsByPaneKey, + previous.retainedAgentsByPaneKey, + relevantRetained, + (left, right) => + left.agentType === right.agentType && + left.worktreeId === right.worktreeId && + left.entry.paneKey === right.entry.paneKey && + left.entry.tabId === right.entry.tabId && + providerSessionEqual(left.entry.providerSession, right.entry.providerSession) + ) + if (!retainedEqual) { + return false + } + + const relevantSleeping = (entry: AppState['sleepingAgentSessionsByPaneKey'][string]): boolean => + isAiVaultTitleAgent(entry.agent) && Boolean(entry.providerSession.id) + return relevantRecordEqual( + current.sleepingAgentSessionsByPaneKey, + previous.sleepingAgentSessionsByPaneKey, + relevantSleeping, + (left, right) => + left.agent === right.agent && + left.paneKey === right.paneKey && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId && + providerSessionEqual(left.providerSession, right.providerSession) + ) +} + +function titleEqual( + left: TerminalTab['aiVaultTitle'], + right: TerminalTab['aiVaultTitle'] +): boolean { + return ( + left?.agent === right?.agent && + left?.sessionId === right?.sessionId && + left?.title === right?.title + ) +} + +function terminalTabsEqual(current: AppState, previous: AppState): boolean { + const currentKeys = Object.keys(current.tabsByWorktree) + const previousKeys = Object.keys(previous.tabsByWorktree) + if (currentKeys.length !== previousKeys.length) { + return false + } + for (const worktreeId of currentKeys) { + const currentTabs = current.tabsByWorktree[worktreeId] + const previousTabs = previous.tabsByWorktree[worktreeId] + if (!previousTabs || currentTabs.length !== previousTabs.length) { + return false + } + for (let index = 0; index < currentTabs.length; index++) { + const left = currentTabs[index] + const right = previousTabs[index] + if ( + left.id !== right.id || + left.worktreeId !== right.worktreeId || + !titleEqual(left.aiVaultTitle, right.aiVaultTitle) + ) { + return false + } + } + } + return true +} + +function activePanesEqual(current: AppState, previous: AppState): boolean { + const currentKeys = Object.keys(current.terminalLayoutsByTabId) + const previousKeys = Object.keys(previous.terminalLayoutsByTabId) + if (currentKeys.length !== previousKeys.length) { + return false + } + return currentKeys.every( + (tabId) => + current.terminalLayoutsByTabId[tabId]?.activeLeafId === + previous.terminalLayoutsByTabId[tabId]?.activeLeafId + ) +} + +function requestOwnersEqual(current: AppState, previous: AppState): boolean { + const worktreeIds = new Set([ + ...collectAiVaultTitleRequests(current).map((request) => request.worktreeId), + ...collectAiVaultTitleRequests(previous).map((request) => request.worktreeId) + ]) + for (const worktreeId of worktreeIds) { + const currentHost = getExecutionHostIdForWorktree(current, worktreeId) + const previousHost = getExecutionHostIdForWorktree(previous, worktreeId) + if (currentHost !== previousHost) { + return false + } + const currentPath = current.getKnownWorktreeById(worktreeId, currentHost)?.path?.trim() || null + const previousPath = + previous.getKnownWorktreeById(worktreeId, previousHost)?.path?.trim() || null + if (currentPath !== previousPath) { + return false + } + } + return true +} + +export function aiVaultTitleSyncInputsChanged(current: AppState, previous: AppState): boolean { + const agentRecordsChanged = + current.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + current.retainedAgentsByPaneKey !== previous.retainedAgentsByPaneKey || + current.sleepingAgentSessionsByPaneKey !== previous.sleepingAgentSessionsByPaneKey + if (agentRecordsChanged && !agentRecordsEqual(current, previous)) { + return true + } + if (current.tabsByWorktree !== previous.tabsByWorktree && !terminalTabsEqual(current, previous)) { + return true + } + if ( + current.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId && + !activePanesEqual(current, previous) + ) { + return true + } + + const ownerInputsChanged = + current.repos !== previous.repos || + current.worktreesByRepo !== previous.worktreesByRepo || + current.detectedWorktreesByRepo !== previous.detectedWorktreesByRepo || + current.folderWorkspaces !== previous.folderWorkspaces || + current.projectGroups !== previous.projectGroups || + current.settings !== previous.settings || + current.activeWorktreeId !== previous.activeWorktreeId || + current.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId || + current.restoredRuntimeHostIdByWorkspaceSessionKey !== + previous.restoredRuntimeHostIdByWorkspaceSessionKey || + current.runtimeEnvironments !== previous.runtimeEnvironments || + current.runtimeEnvironmentCatalogHydrated !== previous.runtimeEnvironmentCatalogHydrated || + current.removedRuntimeEnvironmentIds !== previous.removedRuntimeEnvironmentIds + return ownerInputsChanged && !requestOwnersEqual(current, previous) +} diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts new file mode 100644 index 00000000000..8cf8d09632e --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AiVaultAgent, + AiVaultListResult, + AiVaultSession +} from '../../../shared/ai-vault-types' +import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' +import type { TerminalTab } from '../../../shared/types' +import { + collectAiVaultTitleRequests, + type AiVaultTitleRequest +} from './ai-vault-tab-title-requests' +import { groupAiVaultTitleRequests } from './ai-vault-tab-title-scan-groups' +import { startAiVaultTabTitleSync } from './ai-vault-tab-title-sync' +import type { AppState } from '@/store/types' + +function terminalTab(worktreeId: string, aiVaultTitle?: TerminalTab['aiVaultTitle']): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId, + title: '⠋ albacore', + aiVaultTitle, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session( + agent: Extract, + executionHostId: AiVaultSession['executionHostId'], + title: string +): AiVaultSession { + return { + id: `${executionHostId}:${agent}:${agent}-session:/sessions/${agent}.jsonl`, + executionHostId, + agent, + sessionId: `${agent}-session`, + title, + cwd: '/workspace/albacore', + branch: null, + model: null, + filePath: `/sessions/${agent}.jsonl`, + codexHome: agent === 'codex' ? '/home/dev/.codex' : null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-05T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `${agent} --resume ${agent}-session`, + subagent: null + } +} + +function makeState(args: { + agent?: 'claude' | 'codex' + aiVaultTitle?: TerminalTab['aiVaultTitle'] + executionHostId: 'ssh:dev-box' | 'runtime:server-1' + sleeping?: boolean + path: string + worktreeId: string +}) { + const agent = args.agent ?? 'codex' + const tab = terminalTab(args.worktreeId, args.aiVaultTitle) + const listeners = new Set<(state: AppState, previous: AppState) => void>() + const providerSession = { key: 'session_id' as const, id: `${agent}-session` } + const statusEntry = { + state: 'done' as const, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + agentType: agent, + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: args.worktreeId, + providerSession, + stateHistory: [] + } + let state = { + activeWorktreeId: args.worktreeId, + activeWorkspaceExecutionHostId: args.executionHostId, + agentStatusByPaneKey: args.sleeping ? {} : { 'tab-1:leaf-1': statusEntry }, + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: args.sleeping + ? { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: args.worktreeId, + agent, + providerSession, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + : {}, + tabsByWorktree: { [args.worktreeId]: [tab] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + }, + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + getKnownWorktreeById: () => ({ path: args.path }), + setAiVaultTabTitle: (tabId: string, aiVaultTitle: TerminalTab['aiVaultTitle'] | null) => { + const previous = state + state = { + ...state, + tabsByWorktree: { + [args.worktreeId]: state.tabsByWorktree[args.worktreeId].map((entry: TerminalTab) => + entry.id === tabId ? { ...entry, aiVaultTitle } : entry + ) + } + } + for (const listener of listeners) { + listener(state, previous) + } + } + } as unknown as AppState + return { + getState: () => state, + pingAgentStatus: () => { + const previous = state + state = { + ...state, + agentStatusByPaneKey: Object.fromEntries( + Object.entries(state.agentStatusByPaneKey).map(([paneKey, entry]) => [ + paneKey, + { ...entry, updatedAt: entry.updatedAt + 1 } + ]) + ) + } + for (const listener of listeners) { + listener(state, previous) + } + }, + setProviderSessionId: (sessionId: string) => { + const previous = state + state = { + ...state, + agentStatusByPaneKey: Object.fromEntries( + Object.entries(state.agentStatusByPaneKey).map(([paneKey, entry]) => { + if (!entry.providerSession) { + return [paneKey, entry] + } + return [ + paneKey, + { ...entry, providerSession: { ...entry.providerSession, id: sessionId } } + ] + }) + ) + } + for (const listener of listeners) { + listener(state, previous) + } + }, + removeSleepingRecord: () => { + const previous = state + state = { ...state, sleepingAgentSessionsByPaneKey: {} } + for (const listener of listeners) { + listener(state, previous) + } + }, + subscribe: (listener: (next: AppState, previous: AppState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } +} + +describe('AI Vault tab title sync', () => { + it.each(['claude', 'codex'] as const)( + 'projects the canonical %s AI Vault session title', + async (agent) => { + const store = makeState({ + agent, + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore' + }) + const listSessions = vi.fn( + async (): Promise => ({ + sessions: [session(agent, 'ssh:dev-box', `${agent} conversation`)], + issues: [], + scannedAt: '2026-08-05T00:00:00.000Z' + }) + ) + const stop = startAiVaultTabTitleSync({ ...store, listSessions }) + + await vi.waitFor(() => + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toEqual({ + agent, + sessionId: `${agent}-session`, + title: `${agent} conversation` + }) + ) + expect(listSessions).toHaveBeenCalledWith({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/workspace/albacore'], + limit: 500 + }) + stop() + } + ) + + it('uses runtime host authority and folder workspace paths', () => { + const store = makeState({ + executionHostId: 'runtime:server-1', + worktreeId: 'folder:folder-1', + path: '/srv/folders/albacore' + }) + + expect(collectAiVaultTitleRequests(store.getState())).toEqual([ + expect.objectContaining({ + executionHostId: 'runtime:server-1', + scopePath: '/srv/folders/albacore', + tabId: 'tab-1', + worktreeId: 'folder:folder-1' + }) + ]) + }) + + it('retains a recovered sleeping title after its lifecycle record disappears', async () => { + const store = makeState({ + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore', + sleeping: true + }) + const stop = startAiVaultTabTitleSync({ + ...store, + listSessions: async () => ({ + sessions: [session('codex', 'ssh:dev-box', 'Stable conversation')], + issues: [], + scannedAt: '2026-08-05T00:00:00.000Z' + }) + }) + + await vi.waitFor(() => + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toBeTruthy() + ) + store.removeSleepingRecord() + const restored = store.getState().tabsByWorktree['worktree-1'][0] as TerminalTab + expect(resolveTerminalTabTitle(restored, false)).toBe('Stable conversation') + stop() + }) + + it('refreshes a live title when the AI Vault name changes', async () => { + const store = makeState({ + aiVaultTitle: { agent: 'codex', sessionId: 'codex-session', title: 'First name' }, + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore' + }) + let title = 'First name' + let refresh: (() => void) | undefined + const stop = startAiVaultTabTitleSync({ + ...store, + listSessions: async () => ({ + sessions: [session('codex', 'ssh:dev-box', title)], + issues: [], + scannedAt: '2026-08-05T00:00:00.000Z' + }), + setTimer: (callback) => { + refresh = callback + return 1 + }, + clearTimer: () => {} + }) + + await vi.waitFor(() => expect(refresh).toBeTypeOf('function')) + title = 'Renamed conversation' + refresh?.() + await vi.waitFor(() => + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle?.title).toBe( + 'Renamed conversation' + ) + ) + stop() + }) + + it('does not rescan when a live status ping preserves title inputs', async () => { + const store = makeState({ + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore' + }) + const listSessions = vi.fn(async () => ({ + sessions: [session('codex', 'ssh:dev-box', 'Stable conversation')], + issues: [], + scannedAt: '2026-08-05T00:00:00.000Z' + })) + const stop = startAiVaultTabTitleSync({ ...store, listSessions }) + + await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1)) + store.pingAgentStatus() + await Promise.resolve() + await Promise.resolve() + + expect(listSessions).toHaveBeenCalledTimes(1) + stop() + }) + + it('reconciles immediately when the provider session identity changes', async () => { + const store = makeState({ + executionHostId: 'ssh:dev-box', + worktreeId: 'worktree-1', + path: '/workspace/albacore' + }) + const listSessions = vi.fn(async () => ({ + sessions: [session('codex', 'ssh:dev-box', 'Original conversation')], + issues: [], + scannedAt: '2026-08-05T00:00:00.000Z' + })) + const stop = startAiVaultTabTitleSync({ ...store, listSessions }) + + await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(1)) + store.setProviderSessionId('codex-session-2') + + await vi.waitFor(() => expect(listSessions).toHaveBeenCalledTimes(2)) + expect(store.getState().tabsByWorktree['worktree-1'][0].aiVaultTitle).toBeNull() + stop() + }) + + it('batches workspace scopes per host within the wire bound', () => { + const request = (index: number): AiVaultTitleRequest => ({ + agent: 'codex', + executionHostId: 'ssh:dev-box', + providerSession: { key: 'session_id', id: `session-${index}` }, + refresh: true, + scopePath: `/workspace/project-${index}`, + tabId: `tab-${index}`, + worktreeId: `worktree-${index}` + }) + const groups = groupAiVaultTitleRequests( + Array.from({ length: 65 }, (_, index) => request(index)) + ) + + expect(groups).toHaveLength(2) + expect(groups[0]).toHaveLength(64) + expect(groups[1]).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.ts new file mode 100644 index 00000000000..c3fd8c0d80b --- /dev/null +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.ts @@ -0,0 +1,154 @@ +import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' +import { isAiVaultTitleAgent } from '../../../shared/ai-vault-session-title' +import type { AppState } from '@/store/types' +import { + collectAiVaultTitleRequests, + type AiVaultTitleRequest +} from './ai-vault-tab-title-requests' +import { groupAiVaultTitleRequests } from './ai-vault-tab-title-scan-groups' +import { aiVaultTitleSyncInputsChanged } from './ai-vault-tab-title-sync-inputs' + +const LIVE_TITLE_REFRESH_MS = 20_000 + +function requestIdentity(request: AiVaultTitleRequest): string { + return `${request.executionHostId}\0${request.agent}\0${request.providerSession.id}` +} + +type SyncDependencies = { + getState: () => AppState + listSessions: (args: AiVaultListArgs) => Promise + subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void + setTimer?: (callback: () => void, delay: number) => ReturnType | number + clearTimer?: (timer: ReturnType | number) => void +} + +export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => void { + const setTimer = dependencies.setTimer ?? setTimeout + const clearTimer = + dependencies.clearTimer ?? + ((timer: ReturnType | number) => + clearTimeout(timer as ReturnType)) + let refreshTimer: ReturnType | number | null = null + let scanInFlight = false + let scanAgain = false + let scheduled = false + let stopped = false + let writing = false + + const writeTitle = (request: AiVaultTitleRequest, title: string | null): void => { + writing = true + try { + dependencies + .getState() + .setAiVaultTabTitle( + request.tabId, + title ? { agent: request.agent, sessionId: request.providerSession.id, title } : null + ) + } finally { + writing = false + } + } + + const scanGroup = async (requests: AiVaultTitleRequest[]): Promise => { + const first = requests[0]! + const scopePaths = [...new Set(requests.flatMap((request) => request.scopePath ?? []))] + const result = await dependencies.listSessions({ + executionHostScope: first.executionHostId, + ...(scopePaths.length > 0 ? { scopePaths } : {}), + limit: 500 + }) + if (stopped || result.cancelled) { + return + } + const titleByIdentity = new Map() + for (const session of result.sessions) { + if (isAiVaultTitleAgent(session.agent) && session.title.trim()) { + titleByIdentity.set( + `${session.executionHostId}\0${session.agent}\0${session.sessionId}`, + session.title.trim() + ) + } + } + const currentByTabId = new Map( + collectAiVaultTitleRequests(dependencies.getState()).map((request) => [ + request.tabId, + request + ]) + ) + for (const request of requests) { + const current = currentByTabId.get(request.tabId) + const title = titleByIdentity.get(requestIdentity(request)) + if (current && requestIdentity(current) === requestIdentity(request) && title) { + writeTitle(request, title) + } + } + } + + const reconcile = async (): Promise => { + scheduled = false + if (stopped) { + return + } + if (scanInFlight) { + scanAgain = true + return + } + if (refreshTimer !== null) { + clearTimer(refreshTimer) + refreshTimer = null + } + + const state = dependencies.getState() + const tabsById = new Map( + Object.values(state.tabsByWorktree) + .flat() + .map((tab) => [tab.id, tab] as const) + ) + const requests = collectAiVaultTitleRequests(state) + const requestsToScan = requests.filter((request) => { + const stored = tabsById.get(request.tabId)?.aiVaultTitle + const identityMatches = + stored?.agent === request.agent && stored.sessionId === request.providerSession.id + if (stored && !identityMatches) { + writeTitle(request, null) + } + return request.refresh || !identityMatches || !stored?.title.trim() + }) + + if (requestsToScan.length > 0) { + scanInFlight = true + await Promise.allSettled(groupAiVaultTitleRequests(requestsToScan).map(scanGroup)) + scanInFlight = false + } + + if (scanAgain) { + scanAgain = false + schedule() + } else if (!stopped && requests.some((request) => request.refresh)) { + refreshTimer = setTimer(schedule, LIVE_TITLE_REFRESH_MS) + } + } + + function schedule(): void { + if (scheduled || stopped) { + return + } + scheduled = true + queueMicrotask(() => void reconcile()) + } + + const unsubscribe = dependencies.subscribe((state, previous) => { + if (!writing && aiVaultTitleSyncInputsChanged(state, previous)) { + schedule() + } + }) + schedule() + + return () => { + stopped = true + unsubscribe() + if (refreshTimer !== null) { + clearTimer(refreshTimer) + } + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 1966bed1df1..ebd29bc20b7 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -513,6 +513,7 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr id: tab.id, title: tab.title, quickCommandLabel: tab.quickCommandLabel, + aiVaultTitle: tab.aiVaultTitle, generatedTitle: tab.generatedTitle, customTitle: tab.customTitle, launchAgent: tab.launchAgent @@ -532,7 +533,10 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr } function resolveRuntimeTerminalTitle( - tab: Pick, + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' + >, generatedTitlesEnabled: boolean, liveTitle = tab.title ): string { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 3a597180e05..0f21d8b81a5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -653,6 +653,7 @@ function buildMirroredTerminalTabs( // Why: the host transport carries no generated title, so rebuilding the tab // without this dropped the client's agent-prompt label on every snapshot. ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), + ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), ...(quickCommandLabel ? { quickCommandLabel } : {}), ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, @@ -888,6 +889,7 @@ function buildTerminalUnifiedTab( label: tab.title, ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), customLabel: tab.customTitle, color: tab.color, sortOrder: tab.sortOrder, @@ -1526,6 +1528,9 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.quickCommandLabel === b.quickCommandLabel && a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && a.customTitle === b.customTitle && a.color === b.color && a.sortOrder === b.sortOrder && @@ -1675,6 +1680,9 @@ function tabEqual(a: Tab, b: Tab): boolean { // Why: the generated label is the visible tab title; ignoring it let the // equality bail keep a unified tab that disagreed with its terminal tab. a.generatedLabel === b.generatedLabel && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && a.customLabel === b.customLabel && a.color === b.color && a.sortOrder === b.sortOrder && diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index bdf11e82288..e6b1ae2474e 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -73,6 +73,11 @@ function hydrateUnifiedFormat( .filter((tab) => tab.quickCommandLabel?.trim()) .map((tab) => [tab.id, tab.quickCommandLabel!.trim()]) ) + const aiVaultTitleByTerminalId = new Map( + (session.tabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.aiVaultTitle) + .map((tab) => [tab.id, tab.aiVaultTitle!]) + ) tabsByWorktree[worktreeId] = [...tabs] .map((tab) => ({ ...tab, @@ -86,9 +91,11 @@ function hydrateUnifiedFormat( ? tab.quickCommandLabel.trim() : quickCommandLabelByTerminalId.get(tab.entityId) const generatedLabel = generatedTitleByTerminalId.get(tab.entityId) + const aiVaultTitle = tab.aiVaultTitle ?? aiVaultTitleByTerminalId.get(tab.entityId) return { ...tab, ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(aiVaultTitle ? { aiVaultTitle } : {}), ...(!tab.generatedLabel?.trim() && generatedLabel ? { generatedLabel } : {}) } }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 7ce9dec3d87..10b3ddbda0f 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -666,6 +666,7 @@ export function projectWorktreeTabModelReconciliation( ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), customLabel: tab.customTitle, color: tab.color, sortOrder: tab.sortOrder, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 9edf537671a..7167e1cfc4d 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -51,6 +51,7 @@ import type { SessionOptionValue } from '../../../../shared/native-chat-session- import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' import type { AgentStartedTelemetry } from '../../lib/worktree-activation' +import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' @@ -683,6 +684,7 @@ export type TerminalSlice = { setActiveTab: (tabId: string) => void setActiveTabForWorktree: (worktreeId: string, tabId: string) => void updateTabTitle: (tabId: string, title: string) => void + setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void setGeneratedTabTitleFromAgentPrompt: ( paneKey: string, prompt: string, @@ -2061,6 +2063,37 @@ export const createTerminalSlice: StateCreator }) }, + setAiVaultTabTitle: (tabId, aiVaultTitle) => { + set((s) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) + if (!ownerWorktreeId) { + return s + } + const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] + const current = tabs.find((tab) => tab.id === tabId) + const sameTitle = + current?.aiVaultTitle?.agent === aiVaultTitle?.agent && + current?.aiVaultTitle?.sessionId === aiVaultTitle?.sessionId && + current?.aiVaultTitle?.title === aiVaultTitle?.title + if (!current || sameTitle) { + return s + } + const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, aiVaultTitle } : tab)) + const unifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] + const nextUnifiedTabs = unifiedTabs.map((tab) => + tab.contentType === 'terminal' && tab.entityId === tabId ? { ...tab, aiVaultTitle } : tab + ) + scheduleRuntimeGraphSync() + return { + tabsByWorktree: { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs }, + unifiedTabsByWorktree: { + ...s.unifiedTabsByWorktree, + [ownerWorktreeId]: nextUnifiedTabs + } + } + }) + }, + setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. if (get().settings?.tabAutoGenerateTitle !== true) { @@ -3915,6 +3948,11 @@ export const createTerminalSlice: StateCreator .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) .map((tab) => [tab.entityId, tab.quickCommandLabel!.trim()]) ) + const aiVaultTitleByTerminalId = new Map( + (session.unifiedTabs?.[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'terminal' && tab.aiVaultTitle) + .map((tab) => [tab.entityId, tab.aiVaultTitle!]) + ) return [ worktreeId, [...tabs] @@ -3926,9 +3964,11 @@ export const createTerminalSlice: StateCreator .map((tab, index) => { const quickCommandLabel = tab.quickCommandLabel?.trim() || quickCommandLabelByTerminalId.get(tab.id) + const aiVaultTitle = tab.aiVaultTitle ?? aiVaultTitleByTerminalId.get(tab.id) return { ...clearTransientTerminalState(tab, index), ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(aiVaultTitle ? { aiVaultTitle } : {}), sortOrder: index, pendingActivationSpawn: true } diff --git a/src/shared/ai-vault-session-title.ts b/src/shared/ai-vault-session-title.ts new file mode 100644 index 00000000000..bb3aec0af8a --- /dev/null +++ b/src/shared/ai-vault-session-title.ts @@ -0,0 +1,13 @@ +import type { AiVaultAgent } from './ai-vault-types' + +export type AiVaultSessionTitle = { + agent: Extract + sessionId: string + title: string +} + +export function isAiVaultTitleAgent( + agent: string | null | undefined +): agent is AiVaultSessionTitle['agent'] { + return agent === 'claude' || agent === 'codex' +} diff --git a/src/shared/tab-title-resolution.test.ts b/src/shared/tab-title-resolution.test.ts index 82e4a086245..c8e372e5148 100644 --- a/src/shared/tab-title-resolution.test.ts +++ b/src/shared/tab-title-resolution.test.ts @@ -73,6 +73,78 @@ describe('tab title resolution', () => { ).toBe('Manual label') }) + it('keeps a Codex thread name stable across activity plus project OSC titles', () => { + expect( + resolveTerminalTabTitle( + { + customTitle: null, + aiVaultTitle: { + agent: 'codex', + sessionId: 'codex-session', + title: 'Repair provider-native tab titles' + }, + title: '⠋ albacore' + }, + false + ) + ).toBe('Repair provider-native tab titles') + }) + + it('keeps manual and quick-command labels ahead of AI Vault titles', () => { + const aiVaultTitle = { + agent: 'claude' as const, + sessionId: 'claude-session', + title: 'Claude conversation' + } + expect( + resolveTerminalTabTitle( + { + customTitle: 'Manual label', + quickCommandLabel: 'Run tests', + aiVaultTitle, + title: 'claude working' + }, + false + ) + ).toBe('Manual label') + expect( + resolveTerminalTabTitle( + { + customTitle: null, + quickCommandLabel: 'Run tests', + aiVaultTitle, + title: 'claude working' + }, + false + ) + ).toBe('Run tests') + }) + + it('keeps OpenCode native and Orca-generated title behavior intact', () => { + const aiVaultTitle = { + agent: 'codex' as const, + sessionId: 'codex-session', + title: 'Codex conversation' + } + expect( + resolveTerminalTabTitle( + { + customTitle: null, + aiVaultTitle, + generatedTitle: 'Orca generated', + title: 'OC | OpenCode native' + }, + true + ) + ).toBe('OC | OpenCode native') + expect( + resolveTerminalTabTitle( + { customTitle: null, generatedTitle: 'Orca generated', title: '⠋ albacore' }, + true + ) + ).toBe('Orca generated') + }) + it('uses the same priority for unified tab labels', () => { expect( resolveUnifiedTabLabel( diff --git a/src/shared/tab-title-resolution.ts b/src/shared/tab-title-resolution.ts index 6f1d51042ce..9eea2b62371 100644 --- a/src/shared/tab-title-resolution.ts +++ b/src/shared/tab-title-resolution.ts @@ -2,7 +2,10 @@ import type { Tab, TerminalTab } from './types' import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export function resolveTerminalTabTitle( - tab: Pick, + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' + >, generatedTitlesEnabled: boolean, fallback = '' ): string { @@ -11,6 +14,7 @@ export function resolveTerminalTabTitle( tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || (isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') || + tab.aiVaultTitle?.title.trim() || (generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') || liveTitle || fallback @@ -18,7 +22,9 @@ export function resolveTerminalTabTitle( } export function resolveUnifiedTabLabel( - tab: Pick | undefined, + tab: + | Pick + | undefined, generatedTitlesEnabled: boolean, fallback = '' ): string { @@ -27,6 +33,7 @@ export function resolveUnifiedTabLabel( tab?.customLabel?.trim() || tab?.quickCommandLabel?.trim() || (isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') || + tab?.aiVaultTitle?.title.trim() || (generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') || liveLabel || fallback diff --git a/src/shared/types.ts b/src/shared/types.ts index a3864c39061..1c68a69b54b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -52,6 +52,7 @@ import type { PersistedNativeChatSessionOptions } from './native-chat-session-op import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' import type { TaskSourceContext } from './task-source-context' import type { SetupRunnerShell } from './setup-runner-command' +import type { AiVaultSessionTitle } from './ai-vault-session-title' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -839,6 +840,8 @@ export type Tab = { contentType: TabContentType label: string // display title (auto-derived from PTY or filename) generatedLabel?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null quickCommandLabel?: string | null customLabel: string | null color: string | null @@ -879,6 +882,8 @@ export type TerminalTab = { defaultTitle?: string /** Stable opt-in label derived from the first known agent prompt. */ generatedTitle?: string | null + /** Stable AI Vault conversation name, bound to its provider session identity. */ + aiVaultTitle?: AiVaultSessionTitle | null /** Stable label from the tab-bar Quick Command that created this terminal. */ quickCommandLabel?: string | null customTitle: string | null diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 33799b80cde..72c7f40d2a5 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -235,6 +235,11 @@ describe('parseWorkspaceSession', () => { title: 'Claude working', defaultTitle: 'Terminal 1', generatedTitle: 'Refactor auth', + aiVaultTitle: { + agent: 'codex', + sessionId: 'session-1', + title: 'Provider thread name' + }, customTitle: null, color: null, sortOrder: 0, @@ -253,6 +258,11 @@ describe('parseWorkspaceSession', () => { contentType: 'terminal', label: 'Claude working', generatedLabel: 'Refactor auth', + aiVaultTitle: { + agent: 'codex', + sessionId: 'session-1', + title: 'Provider thread name' + }, customLabel: null, color: null, sortOrder: 0, @@ -265,7 +275,56 @@ describe('parseWorkspaceSession', () => { expect(result.ok).toBe(true) if (result.ok) { expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth') + expect(result.value.tabsByWorktree.wt[0].aiVaultTitle?.title).toBe('Provider thread name') expect(result.value.unifiedTabs?.wt[0].generatedLabel).toBe('Refactor auth') + expect(result.value.unifiedTabs?.wt[0].aiVaultTitle?.title).toBe('Provider thread name') + } + }) + + it('drops malformed AI Vault titles without rejecting the workspace session', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Codex', + aiVaultTitle: { agent: 'future-agent', sessionId: 'session-1', title: 'Name' }, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'tab1', + entityId: 'tab1', + groupId: 'group1', + worktreeId: 'wt', + contentType: 'terminal', + label: 'Codex', + aiVaultTitle: 'malformed', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].aiVaultTitle).toBeUndefined() + expect(result.value.unifiedTabs?.wt[0].aiVaultTitle).toBeUndefined() } }) diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index da5fe4ef08e..7a24ad57505 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -72,6 +72,15 @@ const terminalTabSchema = z.object({ title: z.string(), defaultTitle: z.string().optional(), generatedTitle: z.string().nullable().optional(), + aiVaultTitle: z + .object({ + agent: z.enum(['claude', 'codex']), + sessionId: z.string(), + title: z.string() + }) + .nullable() + .optional() + .catch(undefined), quickCommandLabel: z.string().nullable().optional(), customTitle: z.string().nullable(), color: z.string().nullable(), @@ -112,6 +121,15 @@ const tabSchema = z.object({ contentType: tabContentTypeSchema, label: z.string(), generatedLabel: z.string().nullable().optional(), + aiVaultTitle: z + .object({ + agent: z.enum(['claude', 'codex']), + sessionId: z.string(), + title: z.string() + }) + .nullable() + .optional() + .catch(undefined), quickCommandLabel: z.string().nullable().optional(), customLabel: z.string().nullable(), color: z.string().nullable(), From b81450f08547e2bce5f7f2581e741e7cd7ab7bcd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:42:37 -0700 Subject: [PATCH 54/88] fix(azure-devops): retry with -preview api-version and keep project-level Git base for on-prem Server (STA-3494) (#12832) * fix(azure-devops): retry with -preview api-version and keep project-level Git base for on-prem Server (STA-3494) Azure DevOps Server rejects api-version=7.1 with 400 VssInvalidPreviewVersionException unless the -preview suffix is supplied, so auth and every Git endpoint failed. Retry once with -preview on that rejection and remember the requirement per origin. Also stop letting a same-origin ORCA_AZURE_DEVOPS_API_BASE_URL (collection-level, needed only for the connectionData auth probe) override the project-level base derived from the remote for Git endpoints; cross-origin (proxy) overrides keep working. * fix(azure-devops): constrain preview retry and base override --- .../azure-devops-api-request.test.ts | 134 ++++++++++++++++++ .../azure-devops/azure-devops-api-request.ts | 100 ++++++++++++- src/main/azure-devops/client.test.ts | 31 ++++ .../pull-request-creation.test.ts | 73 ++++++++++ .../azure-devops/pull-request-creation.ts | 59 +++++--- 5 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 src/main/azure-devops/azure-devops-api-request.test.ts diff --git a/src/main/azure-devops/azure-devops-api-request.test.ts b/src/main/azure-devops/azure-devops-api-request.test.ts new file mode 100644 index 00000000000..a804ebcadb0 --- /dev/null +++ b/src/main/azure-devops/azure-devops-api-request.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetAzureDevOpsPreviewApiVersionCache, + requestAzureDevOpsJson, + requestAzureDevOpsJsonAtBase +} from './azure-devops-api-request' +import type { AzureDevOpsRepoRef } from './repository-ref' + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch + +const SERVER_BASE = 'https://ado.example.com:8443/tfs/MyCollection' + +function previewRejection(): Response { + return new Response( + JSON.stringify({ + message: + 'The requested version "7.1" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: "7.1-preview"', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) +} + +function serverRepoRef(): AzureDevOpsRepoRef { + return { + host: 'ado.example.com', + organization: null, + project: 'MyProject', + repository: 'my-repo', + apiBaseUrl: `${SERVER_BASE}/MyProject`, + webBaseUrl: `${SERVER_BASE}/MyProject/_git/my-repo` + } +} + +describe('Azure DevOps API request (STA-3494)', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } + delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL + _resetAzureDevOpsPreviewApiVersionCache() + }) + + afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH + }) + + it('retries with -preview when Azure DevOps Server rejects the api-version', async () => { + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return previewRejection() + } + return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } }) + }) as never + + await expect( + requestAzureDevOpsJsonAtBase(SERVER_BASE, '/_apis/connectionData') + ).resolves.toEqual({ authenticatedUser: { providerDisplayName: 'Server User' } }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + + it('remembers the -preview requirement per origin after the first rejection', async () => { + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return previewRejection() + } + return Response.json({ ok: true }) + }) as never + + const base = 'https://ado-sticky.example.com/tfs/MyCollection' + await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData') + await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData') + // First request learns the suffix; the second must not repeat the 400 round trip. + expect(versions).toEqual(['7.1', '7.1-preview', '7.1-preview']) + }) + + it('does not retry a 400 that is not a preview-version rejection', async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: 'A project name is required.' }, { status: 400 }) + ) + globalThis.fetch = fetchMock as never + + await expect( + requestAzureDevOpsJsonAtBase( + 'https://ado-other.example.com/tfs/Coll', + '/_apis/connectionData' + ) + ).resolves.toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the remote-derived project base for Git endpoints when the configured base shares its origin', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = SERVER_BASE + const paths: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + paths.push(new URL(String(input)).pathname) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + // Collection-level env base must not strip the project segment Git endpoints need. + expect(paths).toEqual(['/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo']) + }) + + it('keeps a cross-origin configured base URL as an override for Git endpoints', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'http://127.0.0.1:8123/acme/Project' + const origins: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + origins.push(new URL(String(input)).origin) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + expect(origins).toEqual(['http://127.0.0.1:8123']) + }) + + it('keeps a same-origin non-ancestor base URL as a Git endpoint override', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/rewrite/MyProject' + const paths: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + paths.push(new URL(String(input)).pathname) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + expect(paths).toEqual(['/rewrite/MyProject/_apis/git/repositories/my-repo']) + }) +}) diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 5fc8f256793..03c7faac3f4 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -3,6 +3,42 @@ import type { AzureDevOpsRepoRef } from './repository-ref' import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 +const DEFAULT_API_VERSION = '7.1' + +// Why (STA-3494): on-prem Azure DevOps Server rejects versioned requests without +// the -preview suffix; remember which origins need it after the first rejection. +const previewApiVersionOrigins = new Set() + +/** @internal - exposed for tests only */ +export function _resetAzureDevOpsPreviewApiVersionCache(): void { + previewApiVersionOrigins.clear() +} + +export function markAzureDevOpsPreviewApiVersionOrigin(origin: string): void { + previewApiVersionOrigins.add(origin) +} + +export function azureDevOpsApiVersionForOrigin( + origin: string, + requested?: string | number +): string { + const version = String(requested ?? DEFAULT_API_VERSION) + return previewApiVersionOrigins.has(origin) && !version.endsWith('-preview') + ? `${version}-preview` + : version +} + +export function isAzureDevOpsPreviewVersionRejection(status: number | null, body: string): boolean { + if (status !== 400) { + return false + } + try { + const parsed = JSON.parse(body) as { typeKey?: unknown } | null + return parsed?.typeKey === 'VssInvalidPreviewVersionException' + } catch { + return false + } +} type AzureDevOpsAuthConfig = { apiBaseUrl: string | null @@ -52,9 +88,30 @@ function authHeaders(config: AzureDevOpsAuthConfig): Record { return {} } -function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string { +function isUrlPathAncestor(ancestor: string, descendant: string): boolean { + try { + const ancestorUrl = new URL(ancestor) + const descendantUrl = new URL(descendant) + const ancestorPath = ancestorUrl.pathname.replace(/\/+$/, '') + const descendantPath = descendantUrl.pathname.replace(/\/+$/, '') + return ( + ancestorUrl.origin === descendantUrl.origin && + (ancestorPath === descendantPath || descendantPath.startsWith(`${ancestorPath}/`)) + ) + } catch { + return false + } +} + +export function resolveAzureDevOpsGitApiBaseUrl(repo: AzureDevOpsRepoRef): string { const configured = getAzureDevOpsAuthConfig().apiBaseUrl - return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl + if (!configured) { + return repo.apiBaseUrl + } + const normalized = normalizeAzureDevOpsApiBaseUrl(configured) + // Why (STA-3494): a configured collection ancestor is the auth-probe URL; + // Git endpoints need the project-level base derived from the remote. + return isUrlPathAncestor(normalized, repo.apiBaseUrl) ? repo.apiBaseUrl : normalized } function apiUrl( @@ -63,13 +120,32 @@ function apiUrl( searchParams?: AzureDevOpsRequestOptions['searchParams'] ): URL { const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`) - const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' } + const params = { + ...searchParams, + 'api-version': azureDevOpsApiVersionForOrigin(url.origin, searchParams?.['api-version']) + } for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)) } return url } +// Reads the body only for a 400 on a non-preview request; consumed either way. +async function shouldRetryWithPreviewApiVersion(url: URL, response: Response): Promise { + if (response.ok || response.status !== 400) { + return false + } + if (url.searchParams.get('api-version')?.endsWith('-preview')) { + return false + } + try { + const body = (await response.json()) as { typeKey?: string | null } | null + return body?.typeKey === 'VssInvalidPreviewVersionException' + } catch { + return false + } +} + export async function requestAzureDevOpsJsonAtBase( baseUrl: string, path: string, @@ -80,14 +156,21 @@ export async function requestAzureDevOpsJsonAtBase( throwOnFailure = false ): Promise { const config = getAzureDevOpsAuthConfig() - try { - const response = await fetch(apiUrl(baseUrl, path, options.searchParams), { + const doFetch = (url: URL): Promise => + fetch(url, { headers: { Accept: 'application/json', ...authHeaders(config) }, signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) + try { + const url = apiUrl(baseUrl, path, options.searchParams) + let response = await doFetch(url) + if (await shouldRetryWithPreviewApiVersion(url, response)) { + markAzureDevOpsPreviewApiVersionOrigin(url.origin) + response = await doFetch(apiUrl(baseUrl, path, options.searchParams)) + } if (!response.ok) { await cancelUnreadResponseBody(response) if (throwOnFailure) { @@ -110,5 +193,10 @@ export function requestAzureDevOpsJson( options: AzureDevOpsRequestOptions = {}, throwOnFailure = false ): Promise { - return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) + return requestAzureDevOpsJsonAtBase( + resolveAzureDevOpsGitApiBaseUrl(repo), + path, + options, + throwOnFailure + ) } diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index b1f857d1bb1..81761bba12f 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -6,6 +6,7 @@ import { getAzureDevOpsPullRequestForBranchOrThrow, normalizeAzureDevOpsApiBaseUrl } from './client' +import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' @@ -39,6 +40,7 @@ describe('Azure DevOps client', () => { process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } gitExecFileAsyncMock.mockReset() _resetAzureDevOpsRepoRefCache() + _resetAzureDevOpsPreviewApiVersionCache() __resetRepoDefaultBranchCacheForTests() }) @@ -65,6 +67,35 @@ describe('Azure DevOps client', () => { }) }) + it('authenticates against an on-prem Server that requires -preview api-versions (STA-3494)', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/tfs/MyCollection' + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + expect(url.pathname).toBe('/tfs/MyCollection/_apis/connectionData') + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return new Response( + JSON.stringify({ + message: 'The requested version "7.1" of the resource is under preview.', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } }) + }) as never + + await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: 'Server User', + baseUrl: 'https://ado.example.com:8443/tfs/MyCollection', + tokenConfigured: true + }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + it('resolves a PR for a branch through repository, PR, and status REST calls', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts index 02a4b4fb74a..7cd401747ff 100644 --- a/src/main/azure-devops/pull-request-creation.test.ts +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -3,6 +3,7 @@ import { createAzureDevOpsPullRequest, isAzureDevOpsReviewCreationAuthenticated } from './pull-request-creation' +import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' @@ -37,6 +38,7 @@ describe('Azure DevOps pull request creation', () => { stderr: '' }) _resetAzureDevOpsRepoRefCache() + _resetAzureDevOpsPreviewApiVersionCache() }) afterEach(() => { @@ -98,6 +100,77 @@ describe('Azure DevOps pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) + it('retries PR creation with -preview when the Server rejects the api-version (STA-3494)', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo\n', + stderr: '' + }) + const versions: (string | null)[] = [] + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + expect(url.pathname).toBe( + '/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo/pullRequests' + ) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return new Response( + JSON.stringify({ + message: 'The requested version "7.1" of the resource is under preview.', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + return Response.json({ + pullRequestId: 51, + title: 'Server create', + status: 'active', + creationDate: '2026-06-01T00:00:00Z', + _links: { + web: { + href: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51' + } + } + }) + }) + globalThis.fetch = fetchMock as never + + await expect( + createAzureDevOpsPullRequest('/repo', { + provider: 'azure-devops', + base: 'main', + head: 'feature/server', + title: 'Server create', + body: 'Body' + }) + ).resolves.toEqual({ + ok: true, + number: 51, + url: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51' + }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + + it('does not retry PR creation when only the error message names the preview exception', async () => { + const fetchMock = vi.fn(async () => + Response.json( + { message: 'Validation failed near VssInvalidPreviewVersionException' }, + { status: 400 } + ) + ) + globalThis.fetch = fetchMock as never + + await expect( + createAzureDevOpsPullRequest('/repo', { + provider: 'azure-devops', + base: 'main', + head: 'feature/azure', + title: 'Do not retry' + }) + ).resolves.toMatchObject({ ok: false, code: 'validation' }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + it('resolves Azure DevOps remotes through the SSH git provider', async () => { const remoteGit = { exec: vi.fn(async () => ({ diff --git a/src/main/azure-devops/pull-request-creation.ts b/src/main/azure-devops/pull-request-creation.ts index 7a3f25c13a3..e190e65a462 100644 --- a/src/main/azure-devops/pull-request-creation.ts +++ b/src/main/azure-devops/pull-request-creation.ts @@ -9,6 +9,12 @@ import { requestHostedReviewJson } from '../source-control/hosted-review-api-request' import { readHostedPullRequestTemplate } from '../source-control/pull-request-template' +import { + azureDevOpsApiVersionForOrigin, + isAzureDevOpsPreviewVersionRejection, + markAzureDevOpsPreviewApiVersionOrigin, + resolveAzureDevOpsGitApiBaseUrl +} from './azure-devops-api-request' import { getAzureDevOpsPullRequestForBranch } from './client' import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers' import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref' @@ -16,7 +22,6 @@ import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref const CREATE_REQUEST_TIMEOUT_MS = 60_000 type AzureDevOpsCreateAuthConfig = { - apiBaseUrl: string | null pat: string | null accessToken: string | null username: string | null @@ -27,16 +32,8 @@ function envValue(name: string): string | null { return value.length > 0 ? value : null } -function normalizeApiBaseUrl(value: string): string { - return value - .trim() - .replace(/\/+$/, '') - .replace(/\/_apis$/i, '') -} - function getAuthConfig(): AzureDevOpsCreateAuthConfig { return { - apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'), pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'), accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'), username: envValue('ORCA_AZURE_DEVOPS_USERNAME') @@ -60,13 +57,43 @@ function authHeaders(config: AzureDevOpsCreateAuthConfig): Record +): Promise { + const url = apiUrl(repo, path) + try { + return await requestHostedReviewJson( + url, + init, + CREATE_REQUEST_TIMEOUT_MS + ) + } catch (error) { + if ( + url.searchParams.get('api-version')?.endsWith('-preview') || + !(error instanceof HostedReviewApiRequestError) || + !isAzureDevOpsPreviewVersionRejection(error.status, error.message) + ) { + throw error + } + markAzureDevOpsPreviewApiVersionOrigin(url.origin) + return requestHostedReviewJson( + apiUrl(repo, path), + init, + CREATE_REQUEST_TIMEOUT_MS + ) + } +} + function encodePathSegment(value: string): string { return encodeURIComponent(value) } @@ -192,8 +219,9 @@ export async function createAzureDevOpsPullRequest( } try { - const raw = await requestHostedReviewJson( - apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`), + const raw = await requestCreatePullRequest( + repo, + `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`, { method: 'POST', headers: { @@ -202,8 +230,7 @@ export async function createAzureDevOpsPullRequest( ...authHeaders(getAuthConfig()) }, body: JSON.stringify(requestBody) - }, - CREATE_REQUEST_TIMEOUT_MS + } ) const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl) if (created) { From ddf64199fafb7a2ea9f2b08a6ee81aa613e37dcd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:57:02 +0000 Subject: [PATCH 55/88] release: v1.4.176-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d800838281b..108a21d5b52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.174-rc.0", + "version": "1.4.176-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b9e98119241fa86271fd177518a3bcc80e70ddad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:10:26 -0700 Subject: [PATCH 56/88] fix(permissions): use standard macOS Local Network request flow (STA-3505) (#12833) * fix(permissions): surface macOS silent Local Network denial with diagnostic and workaround (STA-3505) On macOS 27 beta, NECP silently denies Orca's whole process tree Local Network access: no prompt fires, the app never appears in System Settings, and terminal child processes fail with EHOSTUNREACH. The Settings trigger swallowed the probe's socket error and reported 'unknown' + a 'Permission request sent' toast, indistinguishable from success. Classify the mDNS probe outcome (EHOSTUNREACH/EHOSTDOWN -> denied, clean send -> granted, else unknown), remember the verdict for the status chip, and render an inline diagnostic with the documented NECP re-evaluation workaround when denial is detected. * fix(permissions): avoid false Local Network grants * fix(permissions): use standard Local Network request flow * feat(permissions): add local network connection test * fix(permissions): nest local network connection test * fix(permissions): collapse connection test by default * fix(permissions): emphasize connection test action * fix(permissions): restore outlined connection action --- src/main/ipc/developer-permissions.test.ts | 67 +++++- src/main/ipc/developer-permissions.ts | 9 + .../ipc/local-network-connection-test.test.ts | 89 ++++++++ src/main/ipc/local-network-connection-test.ts | 167 ++++++++++++++ src/preload/api-types.ts | 7 +- src/preload/index.ts | 4 +- .../settings/DeveloperPermissionActions.tsx | 62 ++++++ .../DeveloperPermissionsPane.test.tsx | 62 +++++- .../settings/DeveloperPermissionsPane.tsx | 106 ++++----- .../LocalNetworkConnectionTest.test.tsx | 119 ++++++++++ .../settings/LocalNetworkConnectionTest.tsx | 207 ++++++++++++++++++ .../developer-permission-request-notice.ts | 56 +++++ .../settings/developer-permission-status.ts | 12 +- .../local-network-connection-history.ts | 45 ++++ src/renderer/src/i18n/locales/en.json | 28 ++- src/renderer/src/web/web-preload-api.ts | 10 +- src/shared/developer-permissions-types.ts | 17 ++ 17 files changed, 992 insertions(+), 75 deletions(-) create mode 100644 src/main/ipc/local-network-connection-test.test.ts create mode 100644 src/main/ipc/local-network-connection-test.ts create mode 100644 src/renderer/src/components/settings/DeveloperPermissionActions.tsx create mode 100644 src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx create mode 100644 src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx create mode 100644 src/renderer/src/components/settings/developer-permission-request-notice.ts create mode 100644 src/renderer/src/components/settings/local-network-connection-history.ts diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index 78c8d6e0be0..5bece99b825 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -14,7 +14,8 @@ const { } = vi.hoisted(() => { const handleMock = vi.fn() const socketState = { - sendCallback: null as (() => void) | null + sendCallback: null as ((error?: Error | null) => void) | null, + errorListener: null as ((error: Error) => void) | null } const socketMock = { on: vi.fn(), @@ -65,6 +66,7 @@ vi.mock('../macos-full-disk-access-status', () => ({ getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock })) +import type { DeveloperPermissionState } from '../../shared/developer-permissions-types' import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -91,7 +93,13 @@ describe('registerDeveloperPermissionHandlers', () => { }) createSocketMock.mockClear() socketState.sendCallback = null - socketMock.on.mockClear() + socketState.errorListener = null + socketMock.on.mockReset() + socketMock.on.mockImplementation((event: string, listener: (error: Error) => void) => { + if (event === 'error') { + socketState.errorListener = listener + } + }) socketMock.removeListener.mockClear() socketMock.bind.mockReset() socketMock.bind.mockImplementation((callback: () => void) => callback()) @@ -103,7 +111,7 @@ describe('registerDeveloperPermissionHandlers', () => { _length: number, _port: number, _address: string, - callback: () => void + callback: (error?: Error | null) => void ) => { socketState.sendCallback = callback } @@ -127,6 +135,26 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + function getStatusHandler(): () => Promise { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'developerPermissions:getStatus' + ) + if (!call) { + throw new Error('developerPermissions:getStatus handler not registered') + } + return call[1] as () => Promise + } + + function getOpenSettingsHandler(): (_event: unknown, args: { id: string }) => Promise { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'developerPermissions:openSettings' + ) + if (!call) { + throw new Error('developerPermissions:openSettings handler not registered') + } + return call[1] as (_event: unknown, args: { id: string }) => Promise + } + it('returns the Full Disk Access read-probe status', async () => { getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') registerDeveloperPermissionHandlers() @@ -142,7 +170,7 @@ describe('registerDeveloperPermissionHandlers', () => { }) }) - it('clears the local-network prompt fallback timer when UDP send settles first', async () => { + it('keeps the local-network status unknown when UDP send settles without an error', async () => { registerDeveloperPermissionHandlers() const result = getRequestHandler()({}, { id: 'local-network' }) @@ -161,6 +189,37 @@ describe('registerDeveloperPermissionHandlers', () => { expect(socketMock.close).toHaveBeenCalledTimes(1) }) + it('keeps local-network status unknown when the prompt trigger receives a socket error', async () => { + registerDeveloperPermissionHandlers() + + const result = getRequestHandler()({}, { id: 'local-network' }) + socketState.errorListener?.( + Object.assign(new Error('send EHOSTUNREACH 224.0.0.251:5353'), { + code: 'EHOSTUNREACH' + }) + ) + + await expect(result).resolves.toEqual({ + id: 'local-network', + status: 'unknown', + openedSystemSettings: false + }) + await expect(getStatusHandler()()).resolves.toContainEqual({ + id: 'local-network', + status: 'unknown' + }) + }) + + it('opens the standard macOS Local Network settings pane', async () => { + registerDeveloperPermissionHandlers() + + await getOpenSettingsHandler()({}, { id: 'local-network' }) + + expect(shellOpenExternalMock).toHaveBeenCalledWith( + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork' + ) + }) + it('settles the automation prompt when osascript hangs', async () => { const killMock = vi.fn() execFileMock.mockImplementation(() => ({ kill: killMock })) diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index dc1171b8c7d..cfc10e61aef 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' import { ipcMain, shell, systemPreferences } from 'electron' import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' +import { testLocalNetworkConnection } from './local-network-connection-test' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -16,6 +17,8 @@ const PRIVACY_PANE_URLS: Partial> = { accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', 'full-disk-access': 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles', automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation', + 'local-network': + 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_LocalNetwork', bluetooth: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Bluetooth' } @@ -235,4 +238,10 @@ export function registerDeveloperPermissionHandlers(): void { await openPrivacyPane(args.id) } ) + + ipcMain.handle('developerPermissions:testLocalNetworkConnection', async (_event, args: unknown) => + testLocalNetworkConnection( + args && typeof args === 'object' ? (args as { host?: unknown; port?: unknown }) : {} + ) + ) } diff --git a/src/main/ipc/local-network-connection-test.test.ts b/src/main/ipc/local-network-connection-test.test.ts new file mode 100644 index 00000000000..d9a62863b82 --- /dev/null +++ b/src/main/ipc/local-network-connection-test.test.ts @@ -0,0 +1,89 @@ +import { createServer } from 'node:net' +import { describe, expect, it, vi } from 'vitest' +import { testLocalNetworkConnection } from './local-network-connection-test' + +describe('testLocalNetworkConnection', () => { + it('runs a bounded Electron child probe and reports a verified connection', async () => { + const runChild = vi.fn((_file, _args, _options, callback) => callback(null, '', '')) + + await expect( + testLocalNetworkConnection( + { host: '192.168.1.20', port: 3000 }, + { platform: 'darwin', now: () => 1234, runChild } + ) + ).resolves.toEqual({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: 1234 + }) + expect(runChild).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(['192.168.1.20', '3000']), + expect.objectContaining({ + timeout: 5000, + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }), + expect.any(Function) + ) + }) + + it('rejects a hostname that resolves to loopback in the real child probe', async () => { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener address') + } + + try { + await expect( + testLocalNetworkConnection( + { host: '2130706433', port: address.port }, + { platform: 'darwin' } + ) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + }) + + it.each([ + ['INVALID_TARGET', 'invalid-target'], + ['ETIMEDOUT', 'timeout'], + ['ECONNREFUSED', 'refused'], + ['EHOSTUNREACH', 'unreachable'], + ['ENETUNREACH', 'unreachable'], + ['ENOTFOUND', 'unresolved'] + ] as const)('maps %s without claiming a permission denial', async (stderr, failure) => { + const runChild = vi.fn((_file, _args, _options, callback) => + callback(Object.assign(new Error(stderr), { code: 1 }), '', stderr) + ) + + await expect( + testLocalNetworkConnection( + { host: 'devbox.local', port: 8080 }, + { platform: 'darwin', runChild } + ) + ).resolves.toMatchObject({ ok: false, failure }) + }) + + it.each([ + { host: '127.0.0.1', port: 3000 }, + { host: 'localhost', port: 3000 }, + { host: 'service.localhost', port: 3000 }, + { host: '8.8.8.8', port: 53 }, + { host: 'https://devbox.local', port: 443 }, + { host: 'devbox.local', port: 0 } + ])('rejects a target that does not test a LAN connection: $host:$port', async (target) => { + const runChild = vi.fn() + + await expect( + testLocalNetworkConnection(target, { platform: 'darwin', runChild }) + ).resolves.toMatchObject({ ok: false, failure: 'invalid-target' }) + expect(runChild).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/local-network-connection-test.ts b/src/main/ipc/local-network-connection-test.ts new file mode 100644 index 00000000000..5cc7c35f4ee --- /dev/null +++ b/src/main/ipc/local-network-connection-test.ts @@ -0,0 +1,167 @@ +import { execFile, type ExecFileException, type ExecFileOptions } from 'node:child_process' +import { isIP } from 'node:net' +import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../shared/developer-permissions-types' + +const CONNECT_TIMEOUT_MS = 4_000 +const CHILD_TIMEOUT_MS = CONNECT_TIMEOUT_MS + 1_000 +const CONNECT_SCRIPT = ` +const net = require('node:net') +const host = process.argv[1] +const port = Number(process.argv[2]) +const socket = net.createConnection({ host, port }) +let settled = false +function finish(code) { + if (settled) return + settled = true + if (code) { + process.stderr.write(code) + process.exitCode = 1 + } + socket.destroy() +} +function isLanAddress(address) { + const normalized = (address || '').toLowerCase().split('%', 1)[0] + const mapped = normalized.match(/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/)?.[1] + const ipv4 = mapped || (net.isIP(normalized) === 4 ? normalized : null) + if (ipv4) { + const octets = ipv4.split('.').map(Number) + return octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) || + (octets[0] === 169 && octets[1] === 254) + } + if (net.isIP(normalized) === 6) { + const first = Number.parseInt(normalized.split(':')[0], 16) + return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80 + } + return false +} +socket.setTimeout(${CONNECT_TIMEOUT_MS}) +socket.once('connect', () => finish(isLanAddress(socket.remoteAddress) ? undefined : 'INVALID_TARGET')) +socket.once('timeout', () => finish('ETIMEDOUT')) +socket.once('error', (error) => finish(error.code || 'FAILED')) +` + +type ConnectionChildRunner = ( + file: string, + args: string[], + options: ExecFileOptions, + callback: (error: ExecFileException | null, stdout: string, stderr: string) => void +) => unknown + +type LocalNetworkConnectionTestOptions = { + platform?: NodeJS.Platform + now?: () => number + runChild?: ConnectionChildRunner +} + +function normalizeTargetHost(rawHost: unknown): string | null { + if (typeof rawHost !== 'string') { + return null + } + const host = rawHost.trim().replace(/^\[|\]$/g, '') + if ( + !host || + host.startsWith('-') || + host.length > 253 || + /[\s/\\]/.test(host) || + host.includes('://') || + !/^[a-z0-9.:%_-]+$/i.test(host) + ) { + return null + } + const addressWithoutZone = host.split('%', 1)[0] ?? host + const kind = classifyRemotePairingHostname(addressWithoutZone) + if (isIP(addressWithoutZone) === 0) { + return kind === 'loopback' ? null : host + } + const isLinkLocalIpv4 = addressWithoutZone.startsWith('169.254.') + return kind === 'lan' || isLinkLocalIpv4 ? host : null +} + +function normalizeTargetPort(rawPort: unknown): number | null { + return typeof rawPort === 'number' && + Number.isInteger(rawPort) && + rawPort >= 1 && + rawPort <= 65_535 + ? rawPort + : null +} + +function failureFromChild( + error: ExecFileException, + stderr: string +): LocalNetworkConnectionTestFailure { + if (error.killed || error.signal) { + return 'timeout' + } + const code = stderr.trim().split(/\s/, 1)[0] + switch (code) { + case 'INVALID_TARGET': + return 'invalid-target' + case 'ETIMEDOUT': + return 'timeout' + case 'ECONNREFUSED': + return 'refused' + case 'EHOSTUNREACH': + case 'ENETUNREACH': + return 'unreachable' + case 'ENOTFOUND': + case 'EAI_AGAIN': + return 'unresolved' + default: + return 'failed' + } +} + +export function testLocalNetworkConnection( + args: { host?: unknown; port?: unknown }, + options: LocalNetworkConnectionTestOptions = {} +): Promise { + const testedAt = (options.now ?? Date.now)() + const host = normalizeTargetHost(args.host) + const port = normalizeTargetPort(args.port) + if (!host || !port) { + return Promise.resolve({ + ok: false, + host: typeof args.host === 'string' ? args.host.trim() : '', + port: typeof args.port === 'number' ? args.port : 0, + testedAt, + failure: 'invalid-target' + }) + } + if ((options.platform ?? process.platform) !== 'darwin') { + return Promise.resolve({ ok: false, host, port, testedAt, failure: 'unsupported' }) + } + + const runChild = options.runChild ?? (execFile as ConnectionChildRunner) + return new Promise((resolve) => { + runChild( + process.execPath, + ['-e', CONNECT_SCRIPT, host, String(port)], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: CHILD_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 + }, + (error, _stdout, stderr) => { + if (!error) { + resolve({ ok: true, host, port, testedAt }) + return + } + resolve({ + ok: false, + host, + port, + testedAt, + failure: failureFromChild(error, stderr) + }) + } + ) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1d282a3f867..a6c38a400a3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -432,7 +432,8 @@ import type { import type { DeveloperPermissionId, DeveloperPermissionRequestResult, - DeveloperPermissionState + DeveloperPermissionState, + LocalNetworkConnectionTestResult } from '../shared/developer-permissions-types' import type { ComputerUsePermissionId, @@ -2558,6 +2559,10 @@ export type PreloadApi = { getStatus: () => Promise request: (args: { id: DeveloperPermissionId }) => Promise openSettings: (args: { id: DeveloperPermissionId }) => Promise + testLocalNetworkConnection: (args: { + host: string + port: number + }) => Promise } computerUsePermissions: { getStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index cec72822cee..e2bc12ab4a9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2406,7 +2406,9 @@ const api = { request: (args: { id: string }): Promise => ipcRenderer.invoke('developerPermissions:request', args), openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args) + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) }, computerUsePermissions: { diff --git a/src/renderer/src/components/settings/DeveloperPermissionActions.tsx b/src/renderer/src/components/settings/DeveloperPermissionActions.tsx new file mode 100644 index 00000000000..fa5b52e181a --- /dev/null +++ b/src/renderer/src/components/settings/DeveloperPermissionActions.tsx @@ -0,0 +1,62 @@ +import { ExternalLink } from 'lucide-react' +import { toast } from 'sonner' +import type { + DeveloperPermissionId, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' + +type DeveloperPermissionActionsProps = { + id: DeveloperPermissionId + actionLabel: string + pending: boolean + status: DeveloperPermissionStatus | undefined + onRequest: (id: DeveloperPermissionId) => void +} + +export function DeveloperPermissionActions({ + id, + actionLabel, + pending, + status, + onRequest +}: DeveloperPermissionActionsProps): React.JSX.Element { + const openSettings = async (): Promise => { + try { + await window.api.developerPermissions.openSettings({ id }) + } catch { + toast.error( + translate( + 'auto.components.settings.DeveloperPermissionsPane.openSettingsFailed', + 'Could not open System Settings' + ) + ) + } + } + + return ( +
+ + {id === 'local-network' && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx index f4f97c1bb64..c58fb017ba4 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -7,6 +7,16 @@ import type { DeveloperPermissionState } from '../../../../shared/developer-perm import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { DeveloperPermissionsPane } from './DeveloperPermissionsPane' +const toastMessageMock = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: toastMessageMock, + success: vi.fn() + } +})) + let container: HTMLDivElement let root: Root @@ -19,10 +29,16 @@ beforeEach(() => { { id: 'full-disk-access', status: 'denied' } ] ), - request: vi.fn() + request: vi.fn(async ({ id }: { id: string }) => ({ + id, + status: 'unknown', + openedSystemSettings: false + })), + openSettings: vi.fn() } } }) + toastMessageMock.mockReset() container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -34,6 +50,50 @@ afterEach(async () => { Reflect.deleteProperty(window, 'api') }) +function setPermissionStates(states: DeveloperPermissionState[]): void { + const api = (window as unknown as { api: { developerPermissions: { getStatus: unknown } } }).api + api.developerPermissions.getStatus = vi.fn(async () => states) +} + +it('requests Local Network access without claiming a permission verdict', async () => { + setPermissionStates([{ id: 'local-network', status: 'unknown' }]) + + await act(async () => { + root.render() + }) + + const row = container.querySelector( + '[data-settings-section="developer-permissions-local-network"]' + ) + const [requestButton, settingsButton] = Array.from( + row?.querySelectorAll('button') ?? [] + ) + expect(row?.textContent).toContain('Managed by macOS') + expect(row?.textContent).toContain("macOS does not report this permission's current status") + expect(requestButton?.textContent).toContain('Request Access') + expect(settingsButton?.textContent).toContain('Open System Settings') + + await act(async () => requestButton?.click()) + + const api = window.api.developerPermissions + expect(api.request).toHaveBeenCalledWith({ id: 'local-network' }) + expect(toastMessageMock).toHaveBeenCalledWith( + 'Check for a macOS prompt', + expect.objectContaining({ + description: + 'If prompted, choose Allow. If no prompt appears, open System Settings and enable Orca under Privacy & Security → Local Network.', + action: expect.objectContaining({ label: 'Open System Settings' }) + }) + ) + + const options = toastMessageMock.mock.calls[0]?.[1] as { action?: { onClick?: () => void } } + options.action?.onClick?.() + expect(api.openSettings).toHaveBeenCalledWith({ id: 'local-network' }) + + await act(async () => settingsButton?.click()) + expect(api.openSettings).toHaveBeenCalledTimes(2) +}) + it('highlights the Full Disk Access row for a targeted Settings navigation', async () => { await act(async () => { root.render( diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index 1924d91a81e..18b48aa918b 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -3,7 +3,6 @@ import { Accessibility, Bluetooth, Camera, - ExternalLink, HardDrive, Mic, MonitorUp, @@ -20,10 +19,13 @@ import type { } from '../../../../shared/developer-permissions-types' import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' +import { DeveloperPermissionActions } from './DeveloperPermissionActions' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' import { developerPermissionStatusClass, developerPermissionStatusLabel } from './developer-permission-status' +import { showDeveloperPermissionRequestNotice } from './developer-permission-request-notice' import { TerminalTccAttributionNotice } from './TerminalTccAttributionNotice' export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' @@ -157,18 +159,21 @@ const PERMISSIONS: PermissionDefinition[] = [ { id: 'local-network', get label() { - return translate('auto.components.settings.DeveloperPermissionsPane.e7bb06007c', 'LAN') + return translate( + 'auto.components.settings.DeveloperPermissionsPane.e7bb06007c', + 'Local Network' + ) }, get description() { return translate( 'auto.components.settings.DeveloperPermissionsPane.f903bf20b5', - 'Discovery and access for development servers on your network.' + "Allows terminals and development tools to connect to services on your local network. macOS does not report this permission's current status to Orca." ) }, get actionLabel() { return translate( - 'auto.components.settings.DeveloperPermissionsPane.actionTriggerPrompt', - 'Trigger Prompt' + 'auto.components.settings.DeveloperPermissionsPane.actionRequestAccess', + 'Request Access' ) }, icon: @@ -290,28 +295,9 @@ export function DeveloperPermissionsPane({ if (!mountedRef.current) { return } - if (result.status === 'granted') { - toast.success( - translate( - 'auto.components.settings.DeveloperPermissionsPane.48d87edcd2', - 'Permission granted' - ) - ) - } else if (result.openedSystemSettings) { - toast.message( - translate( - 'auto.components.settings.DeveloperPermissionsPane.fa809e8ada', - 'Opened macOS Privacy & Security' - ) - ) - } else { - toast.message( - translate( - 'auto.components.settings.DeveloperPermissionsPane.66e94d6cf3', - 'Permission request sent' - ) - ) - } + showDeveloperPermissionRequestNotice(result, () => { + void window.api.developerPermissions.openSettings({ id: 'local-network' }) + }) } catch { if (mountedRef.current) { toast.error( @@ -360,43 +346,37 @@ export function DeveloperPermissionsPane({ const settingId = `developer-permissions-${permission.id}` return ( -
-
-
{permission.icon}
-
-
- {permission.label} - - {developerPermissionStatusLabel(status)} - -
-

{permission.description}

-
-
- +
+
{permission.icon}
+
+
+ {permission.label} + + {developerPermissionStatusLabel(permission.id, status)} + +
+

{permission.description}

+
+
+ void request(id)} + /> +
+ {permission.id === 'local-network' && }
) })} diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx new file mode 100644 index 00000000000..ed65eeff194 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { LocalNetworkConnectionTest } from './LocalNetworkConnectionTest' + +let container: HTMLDivElement +let root: Root +const testConnectionMock = vi.fn() + +beforeEach(() => { + localStorage.clear() + testConnectionMock.mockReset() + Object.assign(window, { + api: { + developerPermissions: { + testLocalNetworkConnection: testConnectionMock + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + localStorage.clear() + Reflect.deleteProperty(window, 'api') +}) + +function inputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +async function expandConnectionTest(): Promise { + await act(async () => + container.querySelector('[data-slot="collapsible-trigger"]')?.click() + ) +} + +it('keeps the target form collapsed by default', async () => { + await act(async () => root.render()) + + expect(container.textContent).toContain('Test connection') + expect(container.textContent).toContain('No successful test saved.') + expect(container.querySelector('form')).toBeNull() +}) + +it('saves and displays the last successful target-specific test', async () => { + testConnectionMock.mockResolvedValue({ + ok: true, + host: '192.168.1.20', + port: 3000, + testedAt: new Date('2026-08-06T07:00:00Z').getTime() + }) + await act(async () => root.render()) + await expandConnectionTest() + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, '192.168.1.20') + inputValue(port, '3000') + }) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + + expect(testConnectionMock).toHaveBeenCalledWith({ host: '192.168.1.20', port: 3000 }) + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('192.168.1.20:3000') + + await act(async () => root.render(null)) + await act(async () => root.render()) + await expandConnectionTest() + expect(container.textContent).toContain('192.168.1.20:3000') +}) + +it('keeps the last success visible when a later test fails', async () => { + testConnectionMock + .mockResolvedValueOnce({ + ok: true, + host: 'devbox.local', + port: 8080, + testedAt: 1000 + }) + .mockResolvedValueOnce({ + ok: false, + host: 'devbox.local', + port: 8080, + testedAt: 2000, + failure: 'refused' + }) + await act(async () => root.render()) + await expandConnectionTest() + + const host = container.querySelector('#local-network-test-host')! + const port = container.querySelector('#local-network-test-port')! + await act(async () => { + inputValue(host, 'devbox.local') + inputValue(port, '8080') + }) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + await act(async () => + container.querySelector('button[type="submit"]')?.click() + ) + + expect(container.textContent).toContain('Last verified') + expect(container.textContent).toContain('devbox.local:8080') + expect(container.textContent).toContain('The host responded, but the port refused') + expect(container.textContent).not.toContain('Permission denied') +}) diff --git a/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx new file mode 100644 index 00000000000..5912b72cd01 --- /dev/null +++ b/src/renderer/src/components/settings/LocalNetworkConnectionTest.tsx @@ -0,0 +1,207 @@ +import { useState, type FormEvent } from 'react' +import { CheckCircle2, ChevronDown } from 'lucide-react' +import type { + LocalNetworkConnectionTestFailure, + LocalNetworkConnectionTestResult +} from '../../../../shared/developer-permissions-types' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { + loadLocalNetworkConnectionSuccess, + saveLocalNetworkConnectionSuccess, + type LocalNetworkConnectionSuccess +} from './local-network-connection-history' + +function failureMessage(failure: LocalNetworkConnectionTestFailure | undefined): string { + switch (failure) { + case 'invalid-target': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestInvalidTarget', + 'Enter a hostname or private LAN IP and a port from 1 to 65535.' + ) + case 'timeout': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestTimeout', + 'Connection timed out. Check the target, service, and macOS Local Network settings.' + ) + case 'refused': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestRefused', + 'The host responded, but the port refused the connection.' + ) + case 'unreachable': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnreachable', + 'The target could not be reached.' + ) + case 'unresolved': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnresolved', + 'The hostname could not be resolved.' + ) + case 'unsupported': + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestUnsupported', + 'Connection testing is available in the macOS desktop app.' + ) + case 'failed': + case undefined: + return translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestFailed', + 'The connection test could not be completed.' + ) + } +} + +function formatTarget(success: LocalNetworkConnectionSuccess): string { + return `${success.host}:${success.port}` +} + +export function LocalNetworkConnectionTest(): React.JSX.Element { + const [lastSuccess, setLastSuccess] = useState(loadLocalNetworkConnectionSuccess) + const [open, setOpen] = useState(false) + const [host, setHost] = useState(lastSuccess?.host ?? '') + const [port, setPort] = useState(lastSuccess ? String(lastSuccess.port) : '') + const [running, setRunning] = useState(false) + const [failure, setFailure] = useState(null) + + const submit = async (event: FormEvent): Promise => { + event.preventDefault() + setRunning(true) + setFailure(null) + try { + const result: LocalNetworkConnectionTestResult = + await window.api.developerPermissions.testLocalNetworkConnection({ + host, + port: Number(port) + }) + if (result.ok) { + const success = { + host: result.host, + port: result.port, + testedAt: result.testedAt + } + saveLocalNetworkConnectionSuccess(success) + setLastSuccess(success) + } else { + setFailure(result.failure ?? 'failed') + } + } catch { + setFailure('failed') + } finally { + setRunning(false) + } + } + + return ( + + + + + +
+

+ {translate( + 'auto.components.settings.DeveloperPermissionsPane.connectionTestDescription', + 'Enter a service on another device on your local network. Orca tests the same network path used by terminal tools.' + )} +

+ {failure && ( +

+ {failureMessage(failure)} +

+ )} +
void submit(event)}> +
+ + setHost(event.target.value)} + placeholder="192.168.1.20" + autoComplete="off" + className="w-44" + disabled={running} + /> +
+
+ + setPort(event.target.value)} + placeholder="3000" + className="w-24" + disabled={running} + /> +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/developer-permission-request-notice.ts b/src/renderer/src/components/settings/developer-permission-request-notice.ts new file mode 100644 index 00000000000..47b7e79b680 --- /dev/null +++ b/src/renderer/src/components/settings/developer-permission-request-notice.ts @@ -0,0 +1,56 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types' + +/** Reports only permission outcomes supported by the corresponding macOS API. */ +export function showDeveloperPermissionRequestNotice( + result: DeveloperPermissionRequestResult, + openLocalNetworkSettings: () => void +): void { + if (result.status === 'granted') { + toast.success( + translate( + 'auto.components.settings.DeveloperPermissionsPane.48d87edcd2', + 'Permission granted' + ) + ) + return + } + if (result.openedSystemSettings) { + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.fa809e8ada', + 'Opened macOS Privacy & Security' + ) + ) + return + } + if (result.id === 'local-network') { + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkPromptCheck', + 'Check for a macOS prompt' + ), + { + description: translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkPromptGuidance', + 'If prompted, choose Allow. If no prompt appears, open System Settings and enable Orca under Privacy & Security → Local Network.' + ), + action: { + label: translate( + 'auto.components.settings.DeveloperPermissionsPane.localNetworkOpenSettings', + 'Open System Settings' + ), + onClick: openLocalNetworkSettings + } + } + ) + return + } + toast.message( + translate( + 'auto.components.settings.DeveloperPermissionsPane.66e94d6cf3', + 'Permission request sent' + ) + ) +} diff --git a/src/renderer/src/components/settings/developer-permission-status.ts b/src/renderer/src/components/settings/developer-permission-status.ts index 7da0b4796f1..c7f4ed5f510 100644 --- a/src/renderer/src/components/settings/developer-permission-status.ts +++ b/src/renderer/src/components/settings/developer-permission-status.ts @@ -1,8 +1,12 @@ import { translate } from '@/i18n/i18n' -import type { DeveloperPermissionStatus } from '../../../../shared/developer-permissions-types' +import type { + DeveloperPermissionId, + DeveloperPermissionStatus +} from '../../../../shared/developer-permissions-types' /** Status copy and tone for the macOS developer permission rows. */ export function developerPermissionStatusLabel( + id: DeveloperPermissionId, status: DeveloperPermissionStatus | undefined ): string { switch (status) { @@ -32,6 +36,12 @@ export function developerPermissionStatusLabel( ) case 'unknown': case undefined: + if (id === 'local-network') { + return translate( + 'auto.components.settings.DeveloperPermissionsPane.statusManagedByMacOS', + 'Managed by macOS' + ) + } return translate( 'auto.components.settings.DeveloperPermissionsPane.statusCheckManually', 'Check manually' diff --git a/src/renderer/src/components/settings/local-network-connection-history.ts b/src/renderer/src/components/settings/local-network-connection-history.ts new file mode 100644 index 00000000000..8712c065d6a --- /dev/null +++ b/src/renderer/src/components/settings/local-network-connection-history.ts @@ -0,0 +1,45 @@ +import type { LocalNetworkConnectionTestResult } from '../../../../shared/developer-permissions-types' + +const STORAGE_KEY = 'orca.developer-permissions.local-network-last-success.v1' + +export type LocalNetworkConnectionSuccess = Pick< + LocalNetworkConnectionTestResult, + 'host' | 'port' | 'testedAt' +> + +function isSavedSuccess(value: unknown): value is LocalNetworkConnectionSuccess { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Partial + return ( + typeof candidate.host === 'string' && + candidate.host.length > 0 && + Number.isInteger(candidate.port) && + candidate.port! >= 1 && + candidate.port! <= 65_535 && + typeof candidate.testedAt === 'number' && + Number.isFinite(candidate.testedAt) + ) +} + +export function loadLocalNetworkConnectionSuccess(): LocalNetworkConnectionSuccess | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) { + return null + } + const parsed: unknown = JSON.parse(raw) + return isSavedSuccess(parsed) ? parsed : null + } catch { + return null + } +} + +export function saveLocalNetworkConnectionSuccess(success: LocalNetworkConnectionSuccess): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(success)) + } catch { + // The live result remains useful when browser storage is unavailable. + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a0c23b47814..926a8f2e48a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5874,8 +5874,8 @@ "b2210b1b4f": "Bluetooth", "dfbc12c8c8": "Hardware debugging and device tools that talk to USB devices.", "bf51e4a542": "USB Devices", - "f903bf20b5": "Discovery and access for development servers on your network.", - "e7bb06007c": "LAN", + "f903bf20b5": "Allows terminals and development tools to connect to services on your local network. macOS does not report this permission's current status to Orca.", + "e7bb06007c": "Local Network", "4a73f5217a": "Apple Events for scripts that control other local apps.", "e119f0d66b": "Automation", "7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca to reduce those prompts. Then quit and reopen Orca.", @@ -5898,7 +5898,29 @@ "statusRestricted": "Restricted", "statusUnsupported": "macOS only", "statusEntitled": "Entitled", - "statusCheckManually": "Check manually" + "statusCheckManually": "Check manually", + "actionRequestAccess": "Request Access", + "localNetworkPromptCheck": "Check for a macOS prompt", + "localNetworkPromptGuidance": "If prompted, choose Allow. If no prompt appears, open System Settings and enable Orca under Privacy & Security → Local Network.", + "localNetworkOpenSettings": "Open System Settings", + "openSettingsFailed": "Could not open System Settings", + "localNetworkOpenSystemSettings": "Open System Settings", + "statusManagedByMacOS": "Managed by macOS", + "connectionTestInvalidTarget": "Enter a hostname or private LAN IP and a port from 1 to 65535.", + "connectionTestTimeout": "Connection timed out. Check the target, service, and macOS Local Network settings.", + "connectionTestRefused": "The host responded, but the port refused the connection.", + "connectionTestUnreachable": "The target could not be reached.", + "connectionTestUnresolved": "The hostname could not be resolved.", + "connectionTestUnsupported": "Connection testing is available in the macOS desktop app.", + "connectionTestFailed": "The connection test could not be completed.", + "connectionTestTitle": "Test connection", + "connectionTestDescription": "Enter a service on another device on your local network. Orca tests the same network path used by terminal tools.", + "connectionTestLastVerified": "Last verified", + "connectionTestNotYetVerified": "No successful test saved.", + "connectionTestHost": "Host", + "connectionTestPort": "Port", + "connectionTestRunning": "Testing...", + "connectionTestAction": "Test Connection" }, "ExperimentalPane": { "9762364929": "Uses APFS clone-copy on macOS when possible, otherwise symlinks configured folders or files into created worktrees.", diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 131be65a6df..0067f122ae6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2927,7 +2927,15 @@ function createDeveloperPermissionsApi(): NonNullable['devel getStatus: () => Promise.resolve([]), request: ({ id }) => Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), - openSettings: () => Promise.resolve() + openSettings: () => Promise.resolve(), + testLocalNetworkConnection: ({ host, port }) => + Promise.resolve({ + ok: false, + host, + port, + testedAt: Date.now(), + failure: 'unsupported' + } as const) } } diff --git a/src/shared/developer-permissions-types.ts b/src/shared/developer-permissions-types.ts index 26c7633f021..c97762df028 100644 --- a/src/shared/developer-permissions-types.ts +++ b/src/shared/developer-permissions-types.ts @@ -28,3 +28,20 @@ export type DeveloperPermissionRequestResult = { status: DeveloperPermissionStatus openedSystemSettings: boolean } + +export type LocalNetworkConnectionTestFailure = + | 'invalid-target' + | 'timeout' + | 'refused' + | 'unreachable' + | 'unresolved' + | 'failed' + | 'unsupported' + +export type LocalNetworkConnectionTestResult = { + ok: boolean + host: string + port: number + testedAt: number + failure?: LocalNetworkConnectionTestFailure +} From fe789c5d52e76fad654944cbd7c66da253aaa8b6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:36:21 -0700 Subject: [PATCH 57/88] fix(renderer): contain corrupt lazy chunks when the recovery reload never lands (#12950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(renderer): contain corrupt lazy chunks when the recovery reload never lands 9 react-error-boundary crash reports across v1.4.171-1.4.175 (macOS, Linux, Windows) all end the same way: a corrupt lazy chunk fails to import, recovery requests a reload, the reload never lands, and loadLazyWithRetry re-throws the raw SyntaxError/TypeError. RecoverableRenderErrorBoundary only suppresses LazyChunkLoadError, so the raw error files a user-facing crash report. LazyChunkLoadError was unreachable in production. Its precondition is a guard written by a *different* document ('reload-landed'), but the finally block clears that guard before the throw, so the only path that could construct it never ran. Confirmed by the shipped bundles: 16/16 lazy_chunk_reload_vetoed breadcrumbs carry outcome=never-landed, zero carry any other outcome, and no bundle contains a boundary-degraded breadcrumb. Route every exhausted-recovery path through exhaustedRecoveryFailure() so an attempted-and-failed recovery yields a LazyChunkLoadError the boundary can contain, and record a lazy_chunk_recovery_exhausted breadcrumb carrying the call site, the real chunk error, and the outcome. Deliberately unchanged: when recovery is never *attempted* (no window/SSR, blocked sessionStorage, guard write failure) the raw error is still thrown so normal crash reporting is unaffected. Only isKnownDynamicImportFailure matches are contained, so module logic bugs keep reporting. * perf(renderer): trim redundant work on the lazy-chunk failure path Hoist the dynamic-import message patterns to module scope so classification stops allocating seven RegExp objects per call, thread the already-computed classification into exhaustedRecoveryFailure so the guard-not-landed path does not re-run it, and bound recordedExhaustionKeys the way the breadcrumb and renderer-error key stores are bounded, since error.name is library-controlled. Failure path only; the success path is unchanged. * refactor(renderer): remove a transposition trap on the lazy-chunk failure path exhaustedRecoveryFailure ended in two adjacent booleans with opposite consequences: transposing them would have returned the raw SyntaxError and silently restored the crash this branch fixes, with no test able to catch it (the only call site passed true for both). The isChunkFailure parameter saved one regex scan on a path that only runs after a 10s reload wait, so drop it. Also evict recordedExhaustionKeys oldest-first instead of clearing wholesale, matching the breadcrumb and renderer-error key stores the comment cites, so an overflow cannot re-open the entire set to a repeat burst. * test(renderer): cover the exhaustion dedupe bound The bound had no coverage, unlike the crash-breadcrumb store it mirrors, so a refactor could drop it or invert the comparison with every test still green. Drive 200 distinct error names through the contained path and assert the set stays capped. Also move MAX_RECORDED_EXHAUSTION_KEYS above the comment that describes the set, not between them. * test(renderer): pin the exhaustion eviction policy, not just the cap The bound test asserted only the size cap, so it stayed green under the old wholesale clear(): after 200 distinct keys a clear-on-overflow leaves 72, which still satisfies the cap. Replay a key that oldest-first eviction retains and assert it emits no second breadcrumb — that fails under clear(), which would otherwise silently re-open the whole set to a repeat burst and flush the 30-entry ring the dedupe exists to protect. * refactor(renderer): cut the breadcrumb machinery down to the actual fix The lazy_chunk_recovery_exhausted breadcrumb was an optional addition that paid for itself in complexity and nothing else: it needed a dedupe set to avoid flushing the 30-entry ring, the set needed a bound because error.name is library-controlled, the bound needed an oldest-first eviction policy, and that needed two more tests plus a boolean parameter that review flagged as a transposition trap. On the dominant never-landed path it did not even fire, because lazy_chunk_reload_vetoed already records the same reloadKey, message and outcome. Drop it. Observability on every path returns to the main baseline, and the fix is what it always was: name an exhausted recovery so the boundary can contain it. Also revert the unrelated regex hoist -- its only caller is the failure path, so the saved allocations are noise. * Verify ordinary errors bypass lazy chunk containment Add test ensuring module evaluation bugs still surface despite never-landed reload attempts. Clarify containment scope: recovery only applies to known dynamic-import failures, not ordinary errors. --- ...ErrorBoundary.never-landed-reload.test.tsx | 90 +++++++++++ .../RecoverableRenderErrorBoundary.tsx | 1 + ...azy-with-retry.never-landed-reload.test.ts | 144 ++++++++++++++++++ src/renderer/src/lib/lazy-with-retry.test.ts | 35 +++-- src/renderer/src/lib/lazy-with-retry.ts | 64 +++++--- 5 files changed, 299 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx create mode 100644 src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx new file mode 100644 index 00000000000..ea32cc99775 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.never-landed-reload.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom + +// The end-to-end shape of the 9 shipped lazy-chunk crash reports: a corrupt chunk +// fails, recovery requests a reload, the reload never lands, and the boundary files +// a react-error-boundary crash report instead of containing the failure. + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry, resetLazyChunkReloadRequestsForTest } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_SETTLE_GRACE_MS = 10_000 + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +describe('RecoverableRenderErrorBoundary after a recovery reload never lands', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + reportCrashMock.mockReset() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + vi.restoreAllMocks() + vi.useRealTimers() + consoleError.mockRestore() + }) + + it('shows the fallback without filing a crash report', async () => { + const LazyCorruptChunk = lazyWithRetry( + () => Promise.reject(new SyntaxError("Unexpected token '}'")), + { retries: 0, reloadKey: 'right-sidebar' } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render( + + + + ) + }) + + // Outlive the reload settle grace window, then let React commit the rejection. + await act(async () => { + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 50) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + // Before the fix the boundary received the raw SyntaxError and filed a report; + // that is exactly what produced all 9 shipped crash reports. + expect(reportCrashMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index eddfbd14252..e4c03e96aa0 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -50,6 +50,7 @@ export class RecoverableRenderErrorBoundary extends React.Component reload requested -> the reload never +// lands -> recovery gives up. 16/16 `lazy_chunk_reload_vetoed` breadcrumbs across +// the shipped bundles carry outcome=never-landed and no bundle contains a +// LazyChunkLoadError, so the boundary receives the raw SyntaxError and files a crash. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +import { + isLazyChunkLoadError, + loadLazyWithRetry, + resetLazyChunkReloadRequestsForTest +} from './lazy-with-retry' + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const RELOAD_SETTLE_GRACE_MS = 10_000 + +// The dominant crash-time message across the shipped bundles (7/9 reports). +const CORRUPT_CHUNK_ERROR = (): SyntaxError => new SyntaxError("Unexpected token '}'") + +type Breadcrumb = { name: string; data: Record } + +function installBreadcrumbSink(): Breadcrumb[] { + const breadcrumbs: Breadcrumb[] = [] + ;(window as unknown as { api: unknown }).api = { + crashReports: { + recordBreadcrumb: (crumb: Breadcrumb) => { + breadcrumbs.push(crumb) + } + } + } + return breadcrumbs +} + +describe('loadLazyWithRetry when the recovery reload never lands', () => { + beforeEach(() => { + vi.useFakeTimers() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + // Production truth: location.reload() produced zero navigations in all 10 + // bundles — no renderer_bootstrap_started follows any lazy_chunk_reload. + vi.spyOn(window.location, 'reload').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + window.sessionStorage.clear() + resetLazyChunkReloadRequestsForTest() + delete (window as unknown as { api?: unknown }).api + }) + + it('surfaces a recognizable LazyChunkLoadError so the boundary can contain it', async () => { + const breadcrumbs = installBreadcrumbSink() + const settled = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'right-sidebar' + }).then( + () => ({ ok: true }) as const, + (error: unknown) => ({ ok: false, error }) as const + ) + + // Let the reload request run, then expire the settle grace window. + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 1) + + const result = await settled + expect(result.ok).toBe(false) + + const vetoed = breadcrumbs.find((crumb) => crumb.name === 'lazy_chunk_reload_vetoed') + expect(vetoed?.data.outcome).toBe('never-landed') + + // The boundary only suppresses LazyChunkLoadError; a raw SyntaxError files a crash report. + expect(isLazyChunkLoadError((result as { error: unknown }).error)).toBe(true) + }) + + it('does not strand a sibling lazy import that fails while a reload is pending', async () => { + installBreadcrumbSink() + const first = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'app.root' + }).catch((error: unknown) => error) + + await vi.advanceTimersByTimeAsync(0) + + // A second surface resolves its lazy chunk while the first reload is still in + // flight — the shape of 131d2ed2 and a7bc7be0, which filed raw crash reports + // 250 ms / 95 ms after the reload request with no explanatory breadcrumb. + const sibling = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0, + reloadKey: 'app.root.sibling' + }).catch((error: unknown) => error) + + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 1) + + expect(isLazyChunkLoadError(await first)).toBe(true) + expect(isLazyChunkLoadError(await sibling)).toBe(true) + }) + + it('contains an unload-vetoed reload and records it as a distinct outcome', async () => { + const breadcrumbs = installBreadcrumbSink() + vi.spyOn(window.location, 'reload').mockImplementation(() => { + window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) + + const settled = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0 + }).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + + const vetoed = breadcrumbs.find((crumb) => crumb.name === 'lazy_chunk_reload_vetoed') + expect(vetoed?.data.outcome).toBe('unload-vetoed') + // A veto is still an attempted-and-failed recovery, so it is contained too. + expect(isLazyChunkLoadError(await settled)).toBe(true) + }) + + it('leaves no guard behind that would block a later document from recovering', async () => { + installBreadcrumbSink() + const settled = loadLazyWithRetry(() => Promise.reject(CORRUPT_CHUNK_ERROR()), { + retries: 0 + }).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 1) + + expect(isLazyChunkLoadError(await settled)).toBe(true) + expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull() + }) + + it('still surfaces ordinary evaluation bugs after a never-landed reload attempt', async () => { + const error = new Error('render bug from lazy module evaluation') + const settled = loadLazyWithRetry(() => Promise.reject(error), { retries: 0 }).catch( + (rejection: unknown) => rejection + ) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(RELOAD_SETTLE_GRACE_MS + 1) + + // Containment is only for known dynamic-import failures; real bugs must still report. + expect(await settled).toBe(error) + expect(isLazyChunkLoadError(error)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts index b38386d2f8c..86b0bcef247 100644 --- a/src/renderer/src/lib/lazy-with-retry.test.ts +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -124,7 +124,7 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('surfaces the original error when the guarded reload never tears the document down', async () => { + it('contains the failure when the guarded reload never tears the document down', async () => { const reload = spyOnReload() stubCrashReportsBreadcrumb() const error = chunkParseError() @@ -146,7 +146,9 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe('pending') await vi.advanceTimersByTimeAsync(10_000) - expect(settled).toBe(error) + // The reload was the last recovery step, so the boundary gets a nameable error. + expect(isLazyChunkLoadError(settled)).toBe(true) + expect(settled).toMatchObject({ cause: error }) expect(reload).toHaveBeenCalledTimes(1) }) @@ -169,23 +171,26 @@ describe('loadLazyWithRetry', () => { expect(isLazyChunkLoadError(caught)).toBe(true) }) - it('reports the original error when the guard belongs to this same document (reload vetoed)', async () => { + it('contains the failure when the guard belongs to this same document (reload vetoed)', async () => { const reload = spyOnReload() window.sessionStorage.setItem(RELOAD_GUARD_KEY, String(performance.timeOrigin)) const error = chunkParseError() const factory = vi.fn(() => Promise.reject(error)) const loaded = loadLazyWithRetry(factory, { retries: 0 }) - const assertion = expect(loaded).rejects.toBe(error) + const assertion = expect(loaded).rejects.toMatchObject({ + name: 'LazyChunkLoadError', + cause: error + }) await vi.advanceTimersByTimeAsync(5000) await assertion expect(reload).not.toHaveBeenCalled() const caught = await loaded.catch((rejection) => rejection) - expect(isLazyChunkLoadError(caught)).toBe(false) + expect(isLazyChunkLoadError(caught)).toBe(true) }) - it('records a lazy_chunk_reload_vetoed breadcrumb in the tick that reports the crash', async () => { + it('records a lazy_chunk_reload_vetoed breadcrumb in the tick that contains the failure', async () => { spyOnReload() const recordBreadcrumb = stubCrashReportsBreadcrumb() window.sessionStorage.setItem(RELOAD_GUARD_KEY, String(performance.timeOrigin)) @@ -195,7 +200,7 @@ describe('loadLazyWithRetry', () => { retries: 0, reloadKey: 'rich-markdown-editor' }) - const assertion = expect(loaded).rejects.toBe(error) + const assertion = expect(loaded).rejects.toMatchObject({ name: 'LazyChunkLoadError' }) await vi.advanceTimersByTimeAsync(1) await assertion @@ -471,7 +476,8 @@ describe('loadLazyWithRetry recovery reload vs the dirty-editor-tab unload veto' await vi.advanceTimersByTimeAsync(5000) expect(harness.navigations).toEqual([]) - expect(settled).toBe(error) + expect(isLazyChunkLoadError(settled)).toBe(true) + expect(settled).toMatchObject({ cause: error }) expect(restartAborted).toHaveBeenCalled() expect(isIntentionalAppRestartInProgress()).toBe(false) expect(recordBreadcrumb).toHaveBeenCalledWith({ @@ -498,7 +504,8 @@ describe('loadLazyWithRetry recovery reload vs the dirty-editor-tab unload veto' reloadKey: 'rich-markdown-editor' }).catch((rejection: unknown) => rejection) - expect(settled).toBe(error) + expect(isLazyChunkLoadError(settled)).toBe(true) + expect(settled).toMatchObject({ cause: error }) expect(harness.hotExitBackups).toBe(1) expect(isIntentionalAppRestartInProgress()).toBe(false) expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull() @@ -531,7 +538,8 @@ describe('loadLazyWithRetry recovery reload vs the dirty-editor-tab unload veto' ) await vi.advanceTimersByTimeAsync(50) - expect(settled).toBe(error) + expect(isLazyChunkLoadError(settled)).toBe(true) + expect(settled).toMatchObject({ cause: error }) expect(vi.getTimerCount()).toBe(0) }) @@ -555,13 +563,14 @@ describe('loadLazyWithRetry recovery reload vs the dirty-editor-tab unload veto' return settled } - expect(await attempt()).toBe(error) + expect(isLazyChunkLoadError(await attempt())).toBe(true) expect(window.sessionStorage.getItem(RELOAD_GUARD_KEY)).toBeNull() - expect(await attempt()).toBe(error) + expect(isLazyChunkLoadError(await attempt())).toBe(true) expect(reload).toHaveBeenCalledTimes(2) - expect(await attempt()).toBe(error) + // Cap reached: still contained, but no third navigation. + expect(isLazyChunkLoadError(await attempt())).toBe(true) expect(reload).toHaveBeenCalledTimes(2) }) diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index b052fa8e0e6..77650b67ded 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -130,6 +130,13 @@ function recordReloadBreadcrumb( const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) +/** Recovery is spent, so name the failure in the one way the boundary can contain. */ +function containedChunkFailure(lastError: unknown, reloadKey: string): unknown { + return isKnownDynamicImportFailure(lastError) + ? new LazyChunkLoadError(lastError, reloadKey) + : lastError +} + function isKnownDynamicImportFailure(error: unknown): boolean { if (!(error instanceof Error)) { return false @@ -140,11 +147,14 @@ function isKnownDynamicImportFailure(error: unknown): boolean { } // Why: a stale/truncated/corrupt chunk parses as invalid JS, so import() - // rejects with a native SyntaxError ("Unexpected token ')'", "Unexpected end - // of input", …). That reaches this catch only from the chunk's fetch+parse - // phase — a recoverable corrupt-chunk failure. Genuine module-evaluation - // logic bugs throw ordinary Errors (still surfaced raw) or fail later during - // React render (outside this load path), so they are unaffected. + // rejects with a native SyntaxError ("Unexpected token '}'", "Unexpected + // string", "Illegal return statement", … — all four observed in shipped + // reports). That reaches this catch only from the chunk's fetch+parse phase. + // Trade-off: a SyntaxError thrown while *evaluating* a lazily imported module + // (e.g. a top-level JSON.parse) is indistinguishable here by message, so it is + // contained too. Stack-frame discrimination was tried and rejected: parser + // errors carry no frames today, but that is not guaranteed across V8 versions + // and a false negative silently restores the crash this guards against. if (error.name === 'SyntaxError') { return true } @@ -190,6 +200,7 @@ export async function loadLazyWithRetry( reloadRequestsThisDocument < MAX_RELOAD_REQUESTS_PER_DOCUMENT ) { if (!markChunkReloadAttempted()) { + // No recovery was attempted, so keep normal reporting rather than containing. throw lastError } reloadRequestsThisDocument += 1 @@ -204,29 +215,38 @@ export async function loadLazyWithRetry( clearChunkReloadGuard() recordReloadBreadcrumb('lazy_chunk_reload_vetoed', reloadKey, failureMessage, outcome) } - throw lastError + // The reload was this document's last recovery step for this chunk, whether it + // was refused outright or simply never navigated. + throw containedChunkFailure(lastError, reloadKey) } - if (reloadGuardState === 'reload-landed' && isKnownDynamicImportFailure(lastError)) { - throw new LazyChunkLoadError(lastError, reloadKey) + if (reloadGuardState === 'reload-landed') { + throw containedChunkFailure(lastError, reloadKey) } - if ( - reloadGuardState === 'reload-not-landed' && - !reloadRequestInFlight && - isKnownDynamicImportFailure(lastError) - ) { - // Record the veto beside the resulting crash report before the ring can evict it. - clearChunkReloadGuard() - recordReloadBreadcrumb( - 'lazy_chunk_reload_vetoed', - reloadKey, - failureMessage, - 'guard-not-landed' - ) + if (reloadGuardState === 'reload-not-landed') { + // A sibling failing under a pending reload must not clear the guard, and an + // unrelated failure must not clear a guard it did not set. + if (!reloadRequestInFlight && isKnownDynamicImportFailure(lastError)) { + // Record the veto before the ring can evict it; the failure is then contained. + clearChunkReloadGuard() + recordReloadBreadcrumb( + 'lazy_chunk_reload_vetoed', + reloadKey, + failureMessage, + 'guard-not-landed' + ) + } + throw containedChunkFailure(lastError, reloadKey) } - // Without a proven reload, preserve normal error-reporting behavior. + if (reloadGuardState === 'not-attempted') { + // The per-document reload cap is spent; further failures cannot recover. + throw containedChunkFailure(lastError, reloadKey) + } + + // No window or no usable storage: recovery was never attempted, so preserve + // normal error reporting instead of containing a failure we never acted on. throw lastError } From 01d3bc5a66d110e6fd90412db38966793f44701d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:09:38 -0700 Subject: [PATCH 58/88] fix(terminal): per-pane WebGL attach latch and fit-anchored reattach (#12945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): per-pane WebGL attach latch and fit-anchored reattach The attach-failure latch was module-global: one pane's failed WebGL context creation stranded every other pane on the DOM renderer — whose cell metrics and rasterization differ visibly (bolder, ~5% wider text) — until the next recovery boundary. The latch is now per-pane. A successful fit additionally offers an event-anchored reattach: a pane that is WebGL-eligible but addon-less (late mount that missed the coalesced reveal repaint, stale fallback) regains WebGL the moment it proves measurable, so a user resize now heals a DOM-stuck pane instead of leaving it. Failed attaches still retry only at recovery boundaries. A webgl-fit-attach diagnostic records each late attach so the stuck state is finally visible in telemetry. Client-size fit helpers move to pane-fit-client-size.ts to stay under the pane-fit.ts line cap. * fix(terminal): refit onto WebGL cell metrics after a fit-anchored attach The fit that triggers the reattach measures DOM cell metrics; WebGL floors the device cell width, so healing a DOM-stuck pane left it on the DOM-derived column count — an unpainted right gutter and a PTY narrower than the pane. Refit on the next frame, mirroring the dispose-side refreshDimensions. Also cover the real wiring: the existing fit-anchored tests drive the signal module directly, so they stay green even if safeFit stops calling it. The new suite goes through safeFit, which is also what proves the import-time hook registration works. * test(terminal): gate the fit-anchored refit frame on a deferred rAF The existing suites stub requestAnimationFrame synchronously, so the window in which the refit handle is live never exists there — nothing covered the two properties that window has to hold. With a deferred stub: - disposing the pane cancels the refit, so it cannot fit (and forward a PTY resize for) an already-disposed terminal; - the deferred fit re-enters the hook exactly once and settles, so there is no fit -> attach -> fit cycle. Both fail against mutated production code (handle kept out of the cancellable slot; addon-less guard dropped). --- .../lib/pane-manager/pane-fit-client-size.ts | 21 ++ .../pane-fit-webgl-attach-signal.ts | 23 ++ src/renderer/src/lib/pane-manager/pane-fit.ts | 24 +- .../lib/pane-manager/pane-manager-types.ts | 5 + .../src/lib/pane-manager/pane-manager.ts | 1 + .../pane-manager/pane-rendering-control.ts | 8 +- .../pane-terminal-gpu-acceleration.ts | 4 +- .../lib/pane-manager/pane-webgl-reattach.ts | 2 +- .../pane-manager/pane-webgl-renderer.test.ts | 232 ++++++++++++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 72 +++++- 10 files changed, 354 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-client-size.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-webgl-attach-signal.ts diff --git a/src/renderer/src/lib/pane-manager/pane-fit-client-size.ts b/src/renderer/src/lib/pane-manager/pane-fit-client-size.ts new file mode 100644 index 00000000000..b858aa46dc8 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-client-size.ts @@ -0,0 +1,21 @@ +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' + +// Why: measure the element FitAddon fits (the xterm host), not the outer .pane — +// a title/banner can shrink the inner fittable area while the outer stays put. +// Round to whole pixels so sub-pixel jitter never reads as a resize. +export function readFitClientSize(pane: ManagedPane): { width: number; height: number } | null { + const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container + const measure = element?.getBoundingClientRect + if (typeof measure !== 'function') { + return null + } + const rect = measure.call(element) + return { width: Math.round(rect.width), height: Math.round(rect.height) } +} + +export function recordPaneFitClientSize(pane: ManagedPane): void { + const size = readFitClientSize(pane) + if (size && size.width > 0 && size.height > 0) { + ;(pane as ManagedPaneInternal).lastFitClientSize = size + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit-webgl-attach-signal.ts b/src/renderer/src/lib/pane-manager/pane-fit-webgl-attach-signal.ts new file mode 100644 index 00000000000..f63edc18154 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-webgl-attach-signal.ts @@ -0,0 +1,23 @@ +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' + +/** + * Why an indirection: the fit path must offer a WebGL reattach opportunity on + * every successful fit (event-anchored recovery — a resize or late mount is the + * moment a DOM-stuck pane can heal), but pane-webgl-renderer already imports + * pane-fit, so pane-fit cannot import it back. The renderer registers here. + */ +type PaneFitWebglAttachHook = (pane: ManagedPaneInternal) => void + +let hook: PaneFitWebglAttachHook | null = null + +export function setPaneFitWebglAttachHook(next: PaneFitWebglAttachHook | null): void { + hook = next +} + +export function notifyPaneFitSucceeded(pane: ManagedPane): void { + try { + hook?.(pane as ManagedPaneInternal) + } catch { + /* ignore — a reattach failure must not fail the fit that triggered it */ + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index 5eda6336169..dcb638c0a1a 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -21,6 +21,8 @@ import { deferTerminalGeometryMutationDuringRebuild, isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' +import { recordPaneFitClientSize } from './pane-fit-client-size' const MIN_PANE_FIT_WIDTH_PX = 48 const MIN_PANE_FIT_HEIGHT_PX = 24 @@ -51,25 +53,7 @@ function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } } -// Why: measure the element FitAddon fits (the xterm host), not the outer .pane — -// a title/banner can shrink the inner fittable area while the outer stays put. -// Round to whole pixels so sub-pixel jitter never reads as a resize. -export function readFitClientSize(pane: ManagedPane): { width: number; height: number } | null { - const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container - const measure = element?.getBoundingClientRect - if (typeof measure !== 'function') { - return null - } - const rect = measure.call(element) - return { width: Math.round(rect.width), height: Math.round(rect.height) } -} - -function recordPaneFitClientSize(pane: ManagedPane): void { - const size = readFitClientSize(pane) - if (size && size.width > 0 && size.height > 0) { - ;(pane as ManagedPaneInternal).lastFitClientSize = size - } -} +export { readFitClientSize } from './pane-fit-client-size' export function canMeasurePaneForFit(pane: ManagedPane): boolean { const measure = pane.container?.getBoundingClientRect @@ -215,6 +199,8 @@ export function flushPendingSafeFitContinuations(pane: ManagedPane): void { export function safeFit(pane: ManagedPane): boolean { const completed = performSafeFit(pane) if (completed) { + // A completed fit proves measurability — the reattach moment for a DOM-stuck pane. + notifyPaneFitSucceeded(pane) // Why: baseline for the reveal fit to tell a real resize from a metric wobble. recordPaneFitClientSize(pane) // Why: replay transactions may be waiting for renderer dimensions; any diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 7f285d17728..21956fad0ec 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -114,6 +114,7 @@ export type PaneRenderingDiagnostics = { gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean webglDisabledAfterContextLoss: boolean + webglAttachFailedSinceRecovery: boolean hasComplexScriptOutput: boolean terminalWebglAutoDecision: TerminalWebglAutoDecision hasWebgl: boolean @@ -141,6 +142,10 @@ export type ManagedPaneInternal = { gpuRenderingEnabled: boolean webglAttachmentDeferred: boolean webglDisabledAfterContextLoss: boolean + // Why per-pane: one pane's failed WebGL attach must not strand every other + // pane on the DOM renderer until the next recovery boundary. Optional so + // absent means "never failed"; only the attach failure path sets it. + webglAttachFailedSinceRecovery?: boolean // Why: expose complex-output diagnostics without changing renderer choice; // auto renderer fallback is reserved for platform or WebGL failures. hasComplexScriptOutput: boolean diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 0f9918525a9..82d6f5b1ada 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -280,6 +280,7 @@ export class PaneManager { gpuRenderingEnabled: pane.gpuRenderingEnabled, webglAttachmentDeferred: pane.webglAttachmentDeferred, webglDisabledAfterContextLoss: pane.webglDisabledAfterContextLoss, + webglAttachFailedSinceRecovery: pane.webglAttachFailedSinceRecovery === true, hasComplexScriptOutput: pane.hasComplexScriptOutput, terminalWebglAutoDecision: getTerminalWebglAutoDecision(), hasWebgl: Boolean(pane.webglAddon) diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 8ad22f07eca..5477fd4f3b0 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -50,11 +50,11 @@ export function suspendPaneRendering(panes: Iterable): void } export function resumePaneRendering(panes: Iterable): void { - // Why: resume (worktree foreground, window wake) is the WebGL retry - // boundary — Chromium may have restored the GPU process since a context - // loss, and bounding retries to resume events cannot loop on live loss. - clearTerminalWebglAttachBackoff() for (const pane of panes) { + // Why: resume (worktree foreground, window wake) is the WebGL retry + // boundary — Chromium may have restored the GPU process since a context + // loss, and bounding retries to resume events cannot loop on live loss. + clearTerminalWebglAttachBackoff(pane) pane.webglAttachmentDeferred = false pane.webglDisabledAfterContextLoss = false reattachWebglIfNeeded(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index 655f9447ebc..86b474196f7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -23,8 +23,10 @@ export function applyTerminalGpuAcceleration( pane.terminalGpuAcceleration = nextMode if (modeChanged) { // Why: an explicit setting change is user intent to re-evaluate the - // renderer; context-loss latches from the old mode should not pin DOM. + // renderer; context-loss and attach-failure latches from the old mode + // should not pin DOM. pane.webglDisabledAfterContextLoss = false + pane.webglAttachFailedSinceRecovery = false } if (!shouldUseTerminalWebgl(pane)) { disposeWebgl(pane, { refreshDimensions: true }) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index 82f8c5d2224..15018a1d185 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -14,6 +14,6 @@ export function rebuildAttachedWebgl(pane: ManagedPaneInternal): void { disposeWebgl(pane) // Why: the live addon just proved context creation works, so a stale attach // backoff from an earlier failure must not downgrade this pane to DOM. - clearTerminalWebglAttachBackoff() + clearTerminalWebglAttachBackoff(pane) attachWebgl(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index 26919ed4d2d..b3990f20400 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -3,9 +3,13 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, + clearTerminalWebglAttachBackoff, resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' +import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' +import { safeFit } from './pane-fit' +import { disposePane } from './pane-lifecycle' function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { const leafId = '22222222-2222-4222-8222-222222222222' as never @@ -46,6 +50,19 @@ function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneIntern } } +function createFittablePane(): ManagedPaneInternal { + const pane = createPane() + const rect = { width: 800, height: 400 } + pane.container = { dataset: {}, getBoundingClientRect: () => rect } as never + pane.xtermContainer = { getBoundingClientRect: () => rect } as never + // Why: WebGL floors the device cell width, so the same box proposes a wider + // grid once the addon is live — the divergence the reattach has to settle. + pane.fitAddon.proposeDimensions = vi.fn(() => + pane.webglAddon ? { cols: 84, rows: 24 } : { cols: 80, rows: 24 } + ) as never + return pane +} + describe('terminal WebGL addon lifecycle', () => { beforeEach(() => { resetTerminalWebglSuggestion() @@ -123,4 +140,219 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).not.toHaveBeenCalled() }) + + it('keeps attaching to healthy panes after another pane fails to attach', () => { + // Regression: the attach-failure latch was module-global, so one pane's + // failed context creation stranded every later pane on the DOM renderer + // (bold/wider text) until the next recovery boundary. + const failing = createPane({ + loadAddon: () => { + throw new Error('WebGL2 not supported null') + } + }) + attachWebgl(failing) + expect(failing.webglAddon).toBeNull() + expect(failing.webglAttachFailedSinceRecovery).toBe(true) + + const healthy = createPane() + attachWebgl(healthy) + + expect(healthy.webglAddon).not.toBeNull() + expect(healthy.webglAttachFailedSinceRecovery).not.toBe(true) + }) + + it('honors the per-pane failure latch until it is cleared', () => { + const loadAddon = vi.fn(() => { + throw new Error('WebGL2 not supported null') + }) + const pane = createPane({ loadAddon }) + + attachWebgl(pane) + attachWebgl(pane) + + // Second attempt must not burn another canvas/getContext while latched. + expect(loadAddon).toHaveBeenCalledTimes(1) + + clearTerminalWebglAttachBackoff(pane) + attachWebgl(pane) + + expect(loadAddon).toHaveBeenCalledTimes(2) + }) +}) + +describe('fit-anchored WebGL reattach', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('attaches WebGL to an eligible addon-less pane when a fit succeeds', () => { + // The event-anchored heal: a user resize (or late mount) proves the pane + // measurable, which is the moment a DOM-stuck pane can regain WebGL. + const pane = createPane() + expect(pane.webglAddon).toBeNull() + + notifyPaneFitSucceeded(pane) + + expect(pane.webglAddon).not.toBeNull() + }) + + it('does not retry a pane whose attach already failed since recovery', () => { + const loadAddon = vi.fn(() => { + throw new Error('WebGL2 not supported null') + }) + const pane = createPane({ loadAddon }) + attachWebgl(pane) + expect(loadAddon).toHaveBeenCalledTimes(1) + + notifyPaneFitSucceeded(pane) + + // Failed attaches retry only at recovery boundaries, never on every fit. + expect(loadAddon).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + + it('leaves suspended and context-loss panes alone on fit', () => { + const deferred = createPane() + deferred.webglAttachmentDeferred = true + notifyPaneFitSucceeded(deferred) + expect(deferred.webglAddon).toBeNull() + + const lost = createPane() + lost.webglDisabledAfterContextLoss = true + notifyPaneFitSucceeded(lost) + expect(lost.webglAddon).toBeNull() + }) + + it('does not disturb a pane that already has a live addon', () => { + const pane = createPane() + attachWebgl(pane) + const liveAddon = pane.webglAddon + expect(liveAddon).not.toBeNull() + + notifyPaneFitSucceeded(pane) + + expect(pane.webglAddon).toBe(liveAddon) + }) +}) + +// Why a separate suite: the tests above drive the signal directly, so they stay +// green even if safeFit stops calling it. These go through the real fit path so +// the wiring — and the import-time hook registration — is what is under test. +describe('safeFit drives the fit-anchored WebGL reattach', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('attaches WebGL when a real safeFit completes', () => { + const pane = createFittablePane() + + expect(safeFit(pane)).toBe(true) + + expect(pane.webglAddon).not.toBeNull() + }) + + it('refits the pane onto the WebGL cell metrics after attaching', () => { + // Without the follow-up fit the healed pane keeps the DOM-derived column + // count, leaving an unpainted gutter and a PTY narrower than the pane. + const pane = createFittablePane() + const hadAddonPerFit: boolean[] = [] + pane.fitAddon.fit = vi.fn(() => { + hadAddonPerFit.push(pane.webglAddon != null) + }) as never + + safeFit(pane) + + expect(hadAddonPerFit).toEqual([true]) + }) + + it('does not fit when the pane is unmeasurable', () => { + const pane = createFittablePane() + pane.container = { + dataset: {}, + getBoundingClientRect: () => ({ width: 0, height: 0 }) + } as never + + expect(safeFit(pane)).toBe(false) + + expect(pane.webglAddon).toBeNull() + }) +}) + +// Why a deferred stub: the suites above run rAF synchronously, so the window in +// which the refit handle is live never exists there — and that window is exactly +// where teardown and re-entry have to hold. +describe('the deferred fit-anchored refit frame', () => { + const frames: (FrameRequestCallback | null)[] = [] + const cancelledFrameIds: number[] = [] + + beforeEach(() => { + frames.length = 0 + cancelledFrameIds.length = 0 + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + frames.push(callback) + ) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + cancelledFrameIds.push(id) + frames[id - 1] = null + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('cancels the refit when the pane is disposed before the frame runs', () => { + // An uncancelled handle refits — and forwards a PTY resize for — a pane + // whose terminal is already disposed. + const pane = createFittablePane() + safeFit(pane) + const refitFrameId = pane.pendingWebglRefreshRafId + expect(refitFrameId).not.toBeNull() + + disposePane(pane, new Map([[pane.id, pane]])) + + expect(cancelledFrameIds).toContain(refitFrameId) + expect(pane.pendingWebglRefreshRafId).toBeNull() + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('settles after one refit instead of cycling fit -> attach -> fit', () => { + const pane = createFittablePane() + + safeFit(pane) + // Bounded drain: a cycle would keep queueing frames past the cap. + for (let index = 0; index < frames.length && index < 8; index += 1) { + const frame = frames[index] + frames[index] = null + frame?.(16) + } + + expect(frames.length).toBe(1) + expect(pane.pendingWebglRefreshRafId).toBeNull() + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 65e78c00802..27b97b7eace 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -7,16 +7,18 @@ import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' -import { safeFitAndThen } from './pane-fit' +import { safeFit, safeFitAndThen } from './pane-fit' +import { setPaneFitWebglAttachHook } from './pane-fit-webgl-attach-signal' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined -// Why: while Chromium refuses WebGL context creation (GPU process crashed or -// WebGL blocked after repeated resets), every attach attempt burns a canvas + -// failed getContext and logs a full-stack warning — and title changes retrigger -// attach constantly in "on" mode. Latch the first failure and skip attempts -// until the next recovery boundary (rendering resume or GPU-setting change). -let webglAttachFailedSinceRecovery = false +// Attach-failure latching is per-pane (pane.webglAttachFailedSinceRecovery): +// while Chromium refuses WebGL context creation, every attach attempt burns a +// canvas + failed getContext and logs a full-stack warning — and title changes +// retrigger attach constantly in "on" mode. Each pane latches its own failure +// and retries at the next recovery boundary (rendering resume or GPU-setting +// change). A module-global latch here previously let ONE pane's failure strand +// every other pane on the DOM renderer until the next boundary. type ReleasableWebglContext = { getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null @@ -31,14 +33,14 @@ type XtermWebglAddonInternals = { export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier - // attach failure suggested DOM rendering for this app session. + // attach failure suggested DOM rendering for this app session. Per-pane + // failure latches are cleared by the callers that iterate panes. suggestedRendererType = undefined - webglAttachFailedSinceRecovery = false resetTerminalWebglAutoDecision() } -export function clearTerminalWebglAttachBackoff(): void { - webglAttachFailedSinceRecovery = false +export function clearTerminalWebglAttachBackoff(pane: ManagedPaneInternal): void { + pane.webglAttachFailedSinceRecovery = false } export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean { @@ -148,6 +150,50 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { } } +function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void { + // Why: the fit that triggered this attach measured DOM cell metrics, but WebGL + // floors the device cell width — keeping that grid leaves an unpainted right + // gutter and a PTY narrower than the pane. Refit on the next frame (mirroring + // the dispose-side refreshDimensions) so xterm has re-measured against the new + // renderer, and so the running fit is never re-entered. + if (typeof globalThis.requestAnimationFrame !== 'function') { + return + } + pane.pendingWebglRefreshRafId = globalThis.requestAnimationFrame(() => { + pane.pendingWebglRefreshRafId = null + try { + safeFit(pane) + } catch { + /* ignore — pane may have been disposed in the meantime */ + } + }) +} + +export function attachWebglAfterFitIfMissing(pane: ManagedPaneInternal): void { + // Why: a successful fit is the event-anchored moment a WebGL-eligible pane + // that is stuck on the DOM renderer can heal — a late mount that missed the + // coalesced reveal repaint, or a fallback whose cause has passed. A user + // resize then repairs the bold/wider DOM-rendered pane instead of leaving it. + // The per-pane failure latch stays honored: genuinely failed attaches retry + // only at recovery boundaries. + if ( + !pane.webglAddon && + pane.gpuRenderingEnabled && + !pane.webglAttachmentDeferred && + !pane.webglDisabledAfterContextLoss && + !pane.webglAttachFailedSinceRecovery && + shouldUseTerminalWebgl(pane) + ) { + attachWebgl(pane) + if (pane.webglAddon) { + recordTerminalWebglDiagnostic('webgl-fit-attach', { paneId: pane.id }) + refitAfterFitAnchoredWebglAttach(pane) + } + } +} + +setPaneFitWebglAttachHook(attachWebglAfterFitIfMissing) + export function attachWebgl(pane: ManagedPaneInternal): void { if ( !ENABLE_WEBGL_RENDERER || @@ -155,7 +201,7 @@ export function attachWebgl(pane: ManagedPaneInternal): void { !shouldUseTerminalWebgl(pane) || pane.webglAttachmentDeferred || pane.webglDisabledAfterContextLoss || - webglAttachFailedSinceRecovery + pane.webglAttachFailedSinceRecovery ) { // Why: nulling the reference here used to leak a still-loaded addon that // kept painting stale frames while every recovery path (atlas reset, @@ -204,7 +250,7 @@ export function attachWebgl(pane: ManagedPaneInternal): void { // enough signal to keep new auto panes on DOM until the setting changes. suggestedRendererType = 'dom' } - webglAttachFailedSinceRecovery = true + pane.webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) try { From 8ddf575fe6209a714beceab8e25350736e3f102b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:33:42 -0700 Subject: [PATCH 59/88] Revert "Remove source control group order preference (#12785)" (#12955) This reverts commit ae1ed5e886acdc4352e5c2de70fed04331386daa. --- .oxlintrc.json | 7 +- .../scripts/relay-watcher-fault-harness.mjs | 22 +++-- .../source-control/mobile-git-status.test.ts | 7 +- .../src/source-control/mobile-git-status.ts | 10 +- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/persistence.test.ts | 49 +++++++--- src/main/persistence.ts | 36 ++++--- src/main/runtime/orca-runtime.ts | 5 +- .../editor/CombinedDiffFileTree.tsx | 55 ++++------- .../GitHubMarkdownComposerEditorPane.tsx | 5 +- .../right-sidebar/SourceControl.tsx | 98 ++++++------------- .../discard-all-sequence.test.ts | 9 -- .../right-sidebar/discard-all-sequence.ts | 5 +- ...ource-control-discard-confirmation.test.ts | 9 -- .../source-control-discard-confirmation.ts | 19 +--- .../source-control-discard-dialog.tsx | 13 +-- .../source-control-section-order.test.ts | 60 ++++++------ .../source-control-section-order.ts | 28 +++--- .../src/components/settings/GitPane.test.ts | 70 ++++++++++++- .../src/components/settings/GitPane.tsx | 91 ++++++++++++++++- .../src/components/settings/git-search.ts | 27 +++++ src/renderer/src/i18n/locales/en.json | 19 ++-- src/renderer/src/i18n/locales/es.json | 12 +++ src/renderer/src/i18n/locales/ja.json | 12 +++ src/renderer/src/i18n/locales/ko.json | 12 +++ src/renderer/src/i18n/locales/zh.json | 12 +++ src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 2 + src/shared/source-control-group-order.test.ts | 20 ++++ src/shared/source-control-group-order.ts | 9 ++ src/shared/types.ts | 3 + ...script-prompt-unreadable-orca-yaml.spec.ts | 5 +- 33 files changed, 469 insertions(+), 268 deletions(-) create mode 100644 src/shared/source-control-group-order.test.ts create mode 100644 src/shared/source-control-group-order.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 0f679f17d52..4cdb4f2c0e2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,5 +150,10 @@ } } ], - "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] + "ignorePatterns": [ + "**/node_modules", + "**/dist", + "**/out", + "tests/e2e/.cross-version-checkouts" + ] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index b27a1f945d9..12e36f72490 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,7 +109,9 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) + new Error( + `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` + ) ) } proc.stdout.on('data', onData) @@ -160,7 +162,11 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) + pollUntil( + () => messages.slice(startIndex).find(predicate), + label, + streams.stderr + ) const request = async (method, params = {}) => { const id = nextSequence++ @@ -311,10 +317,8 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid( - pidFile, - undefined, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => + `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -326,10 +330,8 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid( - pidFile, - firstWatcherPid, - () => `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => + `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index 007fb317ce9..a9e2b554f91 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,8 +29,11 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) - expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) + expect(sections.map((section) => section.title)).toEqual([ + 'Changes', + 'Untracked Files', + 'Staged Changes' + ]) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 2d7b0795918..65fcf61e211 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,13 +59,7 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries - .filter((entry) => - area === 'unstaged' - ? entry.area === 'unstaged' || entry.area === 'untracked' - : entry.area === area - ) - .sort(compareGitStatusEntries) + data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index f802fecb7e7..8d3a52fa823 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -105,6 +105,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 79bd2ea7bbe..47fd0c67850 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -97,6 +97,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 0a48bbbb343..059402ae9ed 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,6 +676,7 @@ describe('Store', () => { const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) + expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') @@ -2783,6 +2784,21 @@ describe('Store', () => { expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) + it('normalizes malformed source control group order on load', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { sourceControlGroupOrder: 'tracked-first' }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + }) + it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5801,21 +5817,15 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('drops retired Source Control order preferences', async () => { - writeDataFile({ - ...getDefaultPersistedState(testState.dir), - settings: { - ...getDefaultPersistedState(testState.dir).settings, - sourceControlGroupOrder: 'changes-first', - sourceControlHierarchyDefaultedV2: true - } - } as never) - + it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { const store = await createStore() - store.flush() - const persisted = readDataFile() as { settings?: Record } - expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') - expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') + expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + + store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) + expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') + + store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) + expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') }) it('updateSettings normalizes terminal shortcut policy', async () => { @@ -5873,15 +5883,18 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - store.updateSettings({ sourceControlViewMode: 'tree' }) + expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + + store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) store.flush() const persisted = readDataFile() as { - settings?: { sourceControlViewMode?: string } + settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') + expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession @@ -5893,9 +5906,13 @@ describe('Store', () => { expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) + expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ + 'settings.sourceControlGroupOrder' + ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') + expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 565b6fd0032..5d581a7f6e6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -183,6 +183,7 @@ import { } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' +import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { @@ -661,22 +662,12 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripRetiredSettingsFields( +function stripLegacyTerminalScrollbackBytes( settings: Partial | undefined ): Partial { - const { - terminalScrollbackBytes: _legacyScrollbackBytes, - sourceControlGroupOrder: _sourceControlGroupOrder, - sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, - ...rest - } = (settings ?? {}) as Partial & { - terminalScrollbackBytes?: unknown - sourceControlGroupOrder?: unknown - sourceControlHierarchyDefaultedV2?: unknown - } + const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? + {}) as Partial & { terminalScrollbackBytes?: unknown } void _legacyScrollbackBytes - void _sourceControlGroupOrder - void _sourceControlHierarchyDefaultedV2 return rest } @@ -3349,6 +3340,15 @@ export class Store { ) { this.loadNeedsSave = true } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + this.loadNeedsSave = true + } result = { ...defaults, ...parsed, @@ -3370,7 +3370,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredSettingsFields(parsed.settings), + ...stripLegacyTerminalScrollbackBytes(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3444,6 +3444,7 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5714,7 +5715,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripRetiredSettingsFields(updates) + const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5782,6 +5783,11 @@ export class Store { updates.terminalShortcutPolicy ) } + if ('sourceControlGroupOrder' in updates) { + sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( + updates.sourceControlGroupOrder + ) + } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a8f2e432d94..5426f28d97c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10764,7 +10764,10 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { + if ( + this.spawnPublishedPtys.has(ptyId) || + this.pendingPtyRegistrationIncarnations.has(ptyId) + ) { return false } // SSH panes have their own lease/reattach machinery. diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index dd6f7b99b93..fc59c2dd75c 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,7 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, @@ -22,11 +22,6 @@ import { import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' -import { - mergeUntrackedIntoChanges, - SOURCE_CONTROL_GROUP_ORDER, - type SourceControlEntryGroups -} from '@/components/right-sidebar/source-control-section-order' export { createCombinedDiffSectionIndexMap, @@ -35,6 +30,7 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' +const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', staged: 'Staged Changes', @@ -43,35 +39,26 @@ const UNCOMMITTED_AREA_LABELS: Record = { function buildUncommittedRows( entries: readonly CombinedDiffFileTreeEntry[], - collapsedDirectoryKeys: ReadonlySet, - areaOrder: readonly GitStagingArea[] + collapsedDirectoryKeys: ReadonlySet ): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] { - const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] } - for (const entry of entries) { - if (isGitStatusEntry(entry)) { - groups[entry.area].push(entry) - } - } - const displayGroups = mergeUntrackedIntoChanges(groups) - - return areaOrder - .map((area) => { - const areaEntries = displayGroups[area] - if (areaEntries.length === 0) { - return null - } - - const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) - return { - area, - label: UNCOMMITTED_AREA_LABELS[area], - rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] - } - }) - .filter( - (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => - Boolean(group) + return UNCOMMITTED_AREA_ORDER.map((area) => { + const areaEntries = entries.filter( + (entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area ) + if (areaEntries.length === 0) { + return null + } + + const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) + return { + area, + label: UNCOMMITTED_AREA_LABELS[area], + rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] + } + }).filter( + (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => + Boolean(group) + ) } function buildBranchRows( @@ -166,7 +153,7 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => mode === 'all' || mode === 'uncommitted' - ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER) + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [], [collapsedDirectoryKeys, filteredEntries, mode] ) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 792799376ea..6914f9b3aba 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,10 +13,7 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
entry.area === 'untracked') + ? getDiscardAllPaths(entries, node.area) + : [] } } @@ -1067,6 +1060,7 @@ function SourceControlInner(): React.JSX.Element { settings?.sourceControlViewMode ) const sourceControlViewMode = persistedSourceControlViewMode + const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder) const [collapsedTreeDirs, setCollapsedTreeDirs] = useState>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(null) @@ -1802,19 +1796,13 @@ function SourceControlInner(): React.JSX.Element { [fileFilterState, grouped] ) - const mergedGrouped = useMemo(() => mergeUntrackedIntoChanges(grouped), [grouped]) - const mergedFilteredGrouped = useMemo( - () => mergeUntrackedIntoChanges(filteredGrouped), - [filteredGrouped] - ) - const displaySections = useMemo( - () => buildSourceControlDisplaySections(mergedFilteredGrouped, SOURCE_CONTROL_GROUP_ORDER), - [mergedFilteredGrouped] + () => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder), + [filteredGrouped, sourceControlGroupOrder] ) const unfilteredDisplaySections = useMemo( - () => buildSourceControlDisplaySections(mergedGrouped, SOURCE_CONTROL_GROUP_ORDER), - [mergedGrouped] + () => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder), + [grouped, sourceControlGroupOrder] ) const unfilteredDisplaySectionsById = useMemo( () => new Map(unfilteredDisplaySections.map((section) => [section.id, section])), @@ -5412,9 +5400,7 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths - ? [...confirmedPaths] - : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) + const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) if (paths.length === 0) { return } @@ -5486,7 +5472,7 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, worktreePath, activeWorktreeId, - mergedGrouped, + grouped, isExecutingBulk, clearSelection, discardMany, @@ -5500,21 +5486,13 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths - ? [...confirmedPaths] - : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) + const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) if (paths.length === 0) { return } - setPendingDiscard({ - kind: 'area', - area, - paths, - hasUntracked: - area === 'unstaged' && mergedGrouped.unstaged.some((entry) => entry.area === 'untracked') - }) + setPendingDiscard({ kind: 'area', area, paths }) }, - [activeWorktreeId, isExecutingBulk, mergedGrouped, worktreePath] + [activeWorktreeId, grouped, isExecutingBulk, worktreePath] ) const requestDiscardEntry = useCallback( @@ -5936,8 +5914,7 @@ function SourceControlInner(): React.JSX.Element { .filter(isStageableStatusEntry) .map((entry) => entry.path) const unstageAllPaths = getUnstageAllPaths(actionItems) - const discardAllPaths = getDiscardAllPaths(actionItems, discardAllAreaFilter(area)) - const discardHasUntracked = actionItems.some((entry) => entry.area === 'untracked') + const discardAllPaths = getDiscardAllPaths(actionItems, area) const canStageAll = !normalizedFilter && stageAllPaths.length > 0 const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0 const canRevertAll = !normalizedFilter && discardAllPaths.length > 0 @@ -5960,7 +5937,7 @@ function SourceControlInner(): React.JSX.Element {
{canRevertAll && ( { event.stopPropagation() @@ -6083,12 +6055,11 @@ function SourceControlInner(): React.JSX.Element { isExecutingBulk={isExecutingBulk} isCollapsed={collapsedTreeDirs.has(node.key)} onToggle={() => toggleTreeDir(node.key)} - onRequestDiscardPaths={(discardArea, paths, hasUntracked) => + onRequestDiscardPaths={(discardArea, paths) => setPendingDiscard({ kind: 'area', area: discardArea, - paths, - hasUntracked + paths }) } onStagePaths={handleStageAllPaths} @@ -7727,11 +7698,7 @@ function SourceControlTreeDirectoryRow({ isExecutingBulk: boolean isCollapsed: boolean onToggle: () => void - onRequestDiscardPaths: ( - area: DiscardAllArea, - paths: readonly string[], - hasUntracked: boolean - ) => void + onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void onStagePaths: (paths: readonly string[]) => Promise onUnstagePaths: (paths: readonly string[]) => Promise }): React.JSX.Element { @@ -7770,30 +7737,21 @@ function SourceControlTreeDirectoryRow({
{canDiscard && ( { event.stopPropagation() - onRequestDiscardPaths( - node.area, - actionPaths.discardPaths, - actionPaths.discardHasUntracked - ) + onRequestDiscardPaths(node.area, actionPaths.discardPaths) }} disabled={isExecutingBulk} /> diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index d8f76c48f3f..e808af6da27 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -30,15 +30,6 @@ describe('getDiscardAllPaths', () => { expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts']) }) - it('accepts mixed Changes areas when untracked files are combined', () => { - const entries: GitStatusEntry[] = [ - entry({ path: 'changed.ts', area: 'unstaged' }), - entry({ path: 'new.ts', area: 'untracked', status: 'untracked' }), - entry({ path: 'ready.ts', area: 'staged' }) - ] - expect(getDiscardAllPaths(entries, ['unstaged', 'untracked'])).toEqual(['changed.ts', 'new.ts']) - }) - it('skips entries with an unresolved conflict', () => { const entries: GitStatusEntry[] = [ entry({ path: 'clean.ts', area: 'unstaged' }), diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index 073ba321a2a..c52854fcd91 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -9,13 +9,12 @@ export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' */ export function getDiscardAllPaths( entries: readonly GitStatusEntry[], - area: DiscardAllArea | readonly DiscardAllArea[] + area: DiscardAllArea ): string[] { - const areas = Array.isArray(area) ? area : [area] return entries .filter( (entry) => - areas.includes(entry.area) && + entry.area === area && entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 6f6632feea8..48e28160af6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -111,13 +111,4 @@ describe('getDiscardAreaConfirmationCopy', () => { confirmLabel: 'Discard all' }) }) - - it('discloses permanent deletion for combined Changes', () => { - expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({ - title: 'Discard changes and delete untracked files?', - description: - 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.', - confirmLabel: 'Delete and discard' - }) - }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts index 03a6805e7f0..ff020c434ec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts @@ -62,25 +62,8 @@ export function getDiscardEntryConfirmationCopy( export function getDiscardAreaConfirmationCopy( area: DiscardAllArea, - count: number, - hasUntracked = false + count: number ): DiscardConfirmationCopy { - if (area === 'unstaged' && hasUntracked) { - return { - title: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle', - 'Discard changes and delete untracked files?' - ), - description: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription', - 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.' - ), - confirmLabel: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm', - 'Delete and discard' - ) - } - } switch (area) { case 'untracked': return { diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx index 25e2366b616..6741a72ac55 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx @@ -20,12 +20,7 @@ import { translate } from '@/i18n/i18n' export type PendingDiscardConfirmation = | { kind: 'entry'; entry: GitStatusEntry } - | { - kind: 'area' - area: DiscardAllArea - paths: readonly string[] - hasUntracked?: boolean - } + | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] } export function focusDiscardDialogConfirmButton( event: Event, @@ -56,11 +51,7 @@ export function SourceControlDiscardDialog({ if (pendingDiscard.kind === 'entry') { return getDiscardEntryConfirmationCopy(pendingDiscard.entry) } - return getDiscardAreaConfirmationCopy( - pendingDiscard.area, - pendingDiscard.paths.length, - pendingDiscard.hasUntracked - ) + return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length) }, [pendingDiscard]) const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2 diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts index 91378833b0d..fa563470d66 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts @@ -4,8 +4,7 @@ import { buildSourceControlDisplaySections, getConflictReviewEntries, getSourceControlSectionViewAction, - mergeUntrackedIntoChanges, - SOURCE_CONTROL_GROUP_ORDER, + resolveSourceControlGroupOrder, splitPinnedSourceControlConflicts, type SourceControlEntryGroups } from './source-control-section-order' @@ -27,38 +26,37 @@ function groups(partial: Partial): SourceControlEntryG } } -describe('SOURCE_CONTROL_GROUP_ORDER', () => { - it('follows the edit, stage, commit workflow', () => { - expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked']) +describe('resolveSourceControlGroupOrder', () => { + it('keeps Changes first by default', () => { + expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked']) }) -}) -describe('mergeUntrackedIntoChanges', () => { - it('folds untracked entries into Changes without changing their Git area', () => { - const unstaged = entry({ area: 'unstaged', path: 'changed.ts' }) - const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' }) - const merged = mergeUntrackedIntoChanges( - groups({ unstaged: [unstaged], untracked: [untracked] }) - ) - - expect(merged.unstaged).toEqual([unstaged, untracked]) - expect(merged.untracked).toEqual([]) - expect(merged.unstaged[1]?.area).toBe('untracked') + it('supports staged-first and untracked-first presets', () => { + expect(resolveSourceControlGroupOrder('staged-first')).toEqual([ + 'staged', + 'unstaged', + 'untracked' + ]) + expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([ + 'untracked', + 'unstaged', + 'staged' + ]) }) }) describe('buildSourceControlDisplaySections', () => { - it('uses the fixed workflow order for normal sections', () => { + it('uses the configured order for normal sections', () => { const sections = buildSourceControlDisplaySections( groups({ staged: [entry({ area: 'staged', path: 'staged.ts' })], unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) - expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked']) + expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked']) }) it('keeps conflicts pinned before the configured normal order', () => { @@ -76,13 +74,13 @@ describe('buildSourceControlDisplaySections', () => { ], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) expect(sections.map((section) => section.id)).toEqual([ 'conflicts', - 'unstaged', 'staged', + 'unstaged', 'untracked' ]) }) @@ -102,7 +100,10 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ unstaged: [unresolved, resolved, normal] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) + const sections = buildSourceControlDisplaySections( + input, + resolveSourceControlGroupOrder('changes-first') + ) expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts']) expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts']) @@ -122,7 +123,10 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ staged: [resolvedStaged, staged] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) + const sections = buildSourceControlDisplaySections( + input, + resolveSourceControlGroupOrder('staged-first') + ) expect(split.pinnedConflicts).toEqual([resolvedStaged]) expect(split.normalGroups.staged).toEqual([staged]) @@ -163,7 +167,7 @@ describe('buildSourceControlDisplaySections', () => { entry({ area: 'unstaged', path: 'normal.ts' }) ] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -187,7 +191,7 @@ describe('buildSourceControlDisplaySections', () => { const normal = entry({ area: 'unstaged', path: 'normal.ts' }) const sections = buildSourceControlDisplaySections( groups({ unstaged: [pinned, normal] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({ @@ -208,7 +212,7 @@ describe('buildSourceControlDisplaySections', () => { groups({ unstaged: [resolved] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('changes-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -236,7 +240,7 @@ describe('buildSourceControlDisplaySections', () => { staged: [staged], unstaged: [unstaged] }), - SOURCE_CONTROL_GROUP_ORDER + resolveSourceControlGroupOrder('staged-first') ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts index 91cc948855b..557db90b556 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -1,4 +1,5 @@ -import type { GitStatusEntry } from '../../../../shared/types' +import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order' +import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types' export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number] @@ -21,23 +22,16 @@ export type SourceControlSectionViewAction = | { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] } | { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] } -export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [ - 'unstaged', - 'staged', - 'untracked' -] +const ORDER_BY_PRESET: Record = { + 'changes-first': ['unstaged', 'staged', 'untracked'], + 'staged-first': ['staged', 'unstaged', 'untracked'], + 'untracked-first': ['untracked', 'unstaged', 'staged'] +} -export function mergeUntrackedIntoChanges( - groups: SourceControlEntryGroups -): SourceControlEntryGroups { - if (groups.untracked.length === 0) { - return groups - } - return { - staged: groups.staged, - unstaged: [...groups.unstaged, ...groups.untracked], - untracked: [] - } +export function resolveSourceControlGroupOrder( + value: SourceControlGroupOrder | null | undefined +): readonly SourceControlSectionArea[] { + return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)] } export function isPinnedConflictEntry(entry: GitStatusEntry): boolean { diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 2947f866fdd..3275eb9a183 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -6,7 +6,12 @@ import { getDefaultSettings } from '../../../../shared/constants' import { translate } from '../../i18n/i18n' import { useAppStore } from '../../store' import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting' -import { GitPane, getGitPaneSearchEntries, shouldShowAutoRenameBranchSetting } from './GitPane' +import { + GitPane, + SourceControlGroupOrderSetting, + getGitPaneSearchEntries, + shouldShowAutoRenameBranchSetting +} from './GitPane' import { TooltipProvider } from '../ui/tooltip' import { matchesSettingsSearch } from './settings-search' import { SettingsSegmentedControl } from './SettingsFormControls' @@ -35,6 +40,23 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } +function findSegmentedControl(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + const label = translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ) + visit(node, (entry) => { + if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) { + found = entry + } + }) + if (!found) { + throw new Error('segmented control not found') + } + return found +} + function findCompareBaseSegmentedControl(node: unknown): ReactElementLike { let found: ReactElementLike | null = null const label = translate( @@ -114,6 +136,52 @@ describe('GitPane', () => { expect(markup).not.toContain('Refresh Local Base Ref') }) + it('renders Source Control group order in Git settings', () => { + const markup = renderGitPane('group order') + + expect(markup).toContain( + translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ) + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.changesFirst', 'Changes first') + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') + ) + expect(markup).toContain( + translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first') + ) + }) + + it('updates Source Control group order only when the selected option changes', () => { + const updateSettings = vi.fn() + const element = SourceControlGroupOrderSetting({ + settings: { + ...getDefaultSettings(os.homedir()), + sourceControlGroupOrder: 'changes-first' + }, + updateSettings + }) + + const control = findSegmentedControl(element) + const onChange = control.props.onChange as (value: string) => void + + onChange('staged-first') + expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' }) + + updateSettings.mockClear() + onChange('changes-first') + expect(updateSettings).not.toHaveBeenCalled() + }) + + it('includes Source Control group order search metadata', () => { + expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true) + expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true) + }) + it('renders the default compare base setting in Git settings', () => { const markup = renderGitPane('compare base') diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 538315e3e7d..da1a34c3fe8 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types' import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types' +import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order' import { Input } from '../ui/input' import { Label } from '../ui/label' import { useAppStore } from '../../store' @@ -19,6 +20,7 @@ import { getKeepLocalMainUpToDateTitle } from './keep-local-main-up-to-date-setting' import { translate } from '@/i18n/i18n' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' export { getGitPaneSearchEntries } @@ -38,6 +40,14 @@ const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [ 'safely', 'worktree' ] +const SOURCE_CONTROL_GROUP_ORDER_KEYWORDS = [ + 'group order', + 'changes first', + 'staged first', + 'untracked first', + 'source control', + 'git changes' +] export function shouldShowAutoRenameBranchSetting( searchQuery: string, @@ -60,6 +70,68 @@ type GitPaneProps = { settingsSearchQuery?: string } +export function SourceControlGroupOrderSetting({ + settings, + updateSettings +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void | Promise +}): React.JSX.Element { + const value = settings.sourceControlGroupOrder ?? DEFAULT_SOURCE_CONTROL_GROUP_ORDER + const title = translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ) + const description = translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', + 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' + ) + + return ( + + + value={value} + onChange={(nextValue) => { + if (nextValue !== value) { + void updateSettings({ sourceControlGroupOrder: nextValue }) + } + }} + ariaLabel={title} + size="sm" + options={[ + { + value: 'changes-first', + label: translate('auto.components.settings.GitPane.changesFirst', 'Changes first') + }, + { + value: 'staged-first', + label: translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') + }, + { + value: 'untracked-first', + label: translate( + 'auto.components.settings.GitPane.untrackedFirst', + 'Untracked first' + ) + } + ]} + /> + } + /> + + ) +} + export function GitPane({ settings, updateSettings, @@ -225,6 +297,23 @@ export function GitPane({ ) : null, + matchesSettingsSearch(searchQuery, { + title: translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ), + description: translate( + 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', + 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' + ), + keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS + }) ? ( + + ) : null, compareAgainstUpstreamMatchesSearch(searchQuery) ? ( [ ...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree') ] }, + { + title: translate( + 'auto.components.settings.git.search.sourceControlGroupOrderTitle', + 'Source Control Group Order' + ), + description: translate( + 'auto.components.settings.git.search.sourceControlGroupOrderDescription', + 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'), + ...translateSearchKeyword( + 'auto.components.settings.git.search.changesFirst', + 'changes first' + ), + ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'), + ...translateSearchKeyword( + 'auto.components.settings.git.search.untrackedFirst', + 'untracked first' + ), + ...translateSearchKeyword( + 'auto.components.settings.git.search.sourceControl', + 'source control' + ), + ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes') + ] + }, { title: translate( 'auto.components.settings.git.search.compareAgainstUpstreamTitle', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 926a8f2e48a..b25a212f7a2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6206,6 +6206,11 @@ "3d172725cc": "None", "1f32ba27a6": "Custom", "a182c5125e": "Git Username", + "sourceControlGroupOrderTitle": "Source Control Group Order", + "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", + "changesFirst": "Changes first", + "stagedFirst": "Staged first", + "untrackedFirst": "Untracked first", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBaseRepositoryDefault": "Repository default", @@ -8481,7 +8486,14 @@ "f83c8937c4": "branch naming", "5ecd91c5ef": "Prefix added to branch names when creating worktrees.", "68bd65fdb8": "Branch Prefix", + "sourceControlGroupOrderTitle": "Source Control Group Order", + "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", + "groupOrder": "group order", + "changesFirst": "changes first", + "stagedFirst": "staged first", + "untrackedFirst": "untracked first", "sourceControl": "source control", + "gitChanges": "git changes", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBase": "compare base", @@ -10822,8 +10834,6 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", - "discardAllMixedUntracked": "Discard changes and delete untracked files", - "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder", "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, @@ -11249,10 +11259,7 @@ "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.", "5c0bdbc4cb": "Restore \"{{value0}}\"?", "d97bf697c9": "This will permanently delete this file. This cannot be undone.", - "96c772bee9": "Delete \"{{value0}}\"?", - "mixedTitle": "Discard changes and delete untracked files?", - "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.", - "mixedConfirm": "Delete and discard" + "96c772bee9": "Delete \"{{value0}}\"?" }, "dialog": { "3bc61dc989": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 60c1f6a7a61..ac0f5c012bf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5966,6 +5966,11 @@ "3d172725cc": "Ninguno", "1f32ba27a6": "Personalizado", "a182c5125e": "Usuario de Git", + "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", + "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", + "changesFirst": "Cambios primero", + "stagedFirst": "Cambios preparados primero", + "untrackedFirst": "Archivos sin seguimiento primero", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBaseRepositoryDefault": "Predeterminada del repositorio", @@ -8231,7 +8236,14 @@ "f83c8937c4": "nombres de ramas", "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", + "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", + "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", + "groupOrder": "orden de grupos", + "changesFirst": "cambios primero", + "stagedFirst": "cambios preparados primero", + "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", + "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBase": "base de comparación", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e9099533abc..d239b240df0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5988,6 +5988,11 @@ "3d172725cc": "なし", "1f32ba27a6": "カスタム", "a182c5125e": "Gitのユーザー名", + "sourceControlGroupOrderTitle": "ソース管理のグループ順序", + "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", + "changesFirst": "変更を先頭", + "stagedFirst": "ステージ済みを先頭", + "untrackedFirst": "未追跡を先頭", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBaseRepositoryDefault": "リポジトリ既定", @@ -8253,7 +8258,14 @@ "f83c8937c4": "ブランチの命名", "5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。", "68bd65fdb8": "ブランチプレフィックス", + "sourceControlGroupOrderTitle": "ソース管理のグループ順序", + "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", + "groupOrder": "グループ順序", + "changesFirst": "変更を先頭", + "stagedFirst": "ステージ済みを先頭", + "untrackedFirst": "未追跡を先頭", "sourceControl": "ソース管理", + "gitChanges": "git の変更", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBase": "比較基準", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5c3ab64d74e..77f37c36a19 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5951,6 +5951,11 @@ "3d172725cc": "없음", "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", + "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", + "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", + "changesFirst": "변경 사항 먼저", + "stagedFirst": "스테이징된 항목 먼저", + "untrackedFirst": "추적되지 않는 파일 먼저", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBaseRepositoryDefault": "저장소 기본값", @@ -8216,7 +8221,14 @@ "f83c8937c4": "브랜치 이름 지정", "5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.", "68bd65fdb8": "브랜치 접두사", + "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", + "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", + "groupOrder": "그룹 순서", + "changesFirst": "변경 사항 먼저", + "stagedFirst": "스테이징된 항목 먼저", + "untrackedFirst": "추적되지 않는 파일 먼저", "sourceControl": "소스 제어", + "gitChanges": "git 변경 사항", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6671acc8ff6..b71ed76c0e6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5963,6 +5963,11 @@ "3d172725cc": "没有任何", "1f32ba27a6": "自定义", "a182c5125e": "git用户名", + "sourceControlGroupOrderTitle": "源代码管理分组顺序", + "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", + "changesFirst": "更改优先", + "stagedFirst": "已暂存优先", + "untrackedFirst": "未跟踪优先", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBaseRepositoryDefault": "仓库默认", @@ -8228,7 +8233,14 @@ "f83c8937c4": "分支命名", "5ecd91c5ef": "创建工作树时添加到分支名称的前缀。", "68bd65fdb8": "分支前缀", + "sourceControlGroupOrderTitle": "源代码管理分组顺序", + "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", + "groupOrder": "分组顺序", + "changesFirst": "更改优先", + "stagedFirst": "已暂存优先", + "untrackedFirst": "未跟踪优先", "sourceControl": "源代码管理", + "gitChanges": "git 更改", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 00d7fd39e48..f3a6ff1b89e 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -23,7 +23,9 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') }) - it('uses a commit-oriented Source Control layout by default', () => {}) + it('keeps Source Control changes first by default', () => { + expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') + }) it('defaults mobile pairing to discovered network addresses', () => { expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c7eb50e18f0..728fec9bd61 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,6 +29,7 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' +import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' @@ -271,6 +272,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, sourceControlViewMode: 'list', + sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/shared/source-control-group-order.test.ts b/src/shared/source-control-group-order.test.ts new file mode 100644 index 00000000000..165d2359c3b --- /dev/null +++ b/src/shared/source-control-group-order.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_SOURCE_CONTROL_GROUP_ORDER, + normalizeSourceControlGroupOrder +} from './source-control-group-order' + +describe('normalizeSourceControlGroupOrder', () => { + it('keeps supported source control group orders', () => { + expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first') + expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first') + expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first') + }) + + it('falls back to the default for malformed values', () => { + expect(normalizeSourceControlGroupOrder('tracked-first')).toBe( + DEFAULT_SOURCE_CONTROL_GROUP_ORDER + ) + expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER) + }) +}) diff --git a/src/shared/source-control-group-order.ts b/src/shared/source-control-group-order.ts new file mode 100644 index 00000000000..acfe5160ac7 --- /dev/null +++ b/src/shared/source-control-group-order.ts @@ -0,0 +1,9 @@ +import type { SourceControlGroupOrder } from './types' + +export const DEFAULT_SOURCE_CONTROL_GROUP_ORDER: SourceControlGroupOrder = 'changes-first' + +export function normalizeSourceControlGroupOrder(value: unknown): SourceControlGroupOrder { + return value === 'changes-first' || value === 'staged-first' || value === 'untracked-first' + ? value + : DEFAULT_SOURCE_CONTROL_GROUP_ORDER +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 1c68a69b54b..cfa041d396f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2704,6 +2704,7 @@ export type OpenInApplication = { } export type SourceControlViewMode = 'list' | 'tree' +export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' @@ -2889,6 +2890,8 @@ export type GlobalSettings = { showGitIgnoredFiles?: boolean /** Preferred Source Control changes layout. Per-user, not per-workspace. */ sourceControlViewMode: SourceControlViewMode + /** Preferred Source Control group order. Per-user, not per-workspace. */ + sourceControlGroupOrder: SourceControlGroupOrder /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ sourceControlCompareAgainstUpstream: boolean /** Whether to show the Orca app name in the titlebar. */ diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 151a4b02bbc..5cc07ef0e5b 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,9 +65,8 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;( - globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } - ).__orcaE2eOrcaYamlUnreadable = false + ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) + .__orcaE2eOrcaYamlUnreadable = false }) } From a025a714472869bb7759aa62d852ec3bb1c53807 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 7 Aug 2026 08:08:28 +0800 Subject: [PATCH 60/88] fix(orchestration): deliver pending mail to already-idle agents (#12584) Co-authored-by: Jinwoo-H --- src/main/runtime/orca-runtime.test.ts | 679 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 159 +++- .../runtime/rpc/methods/orchestration.test.ts | 4 +- .../terminal-send-stale-leaf-liveness.test.ts | 145 +++- .../helpers/orchestration-mail-pane-agent.ts | 157 ++++ tests/e2e/helpers/orchestration-mail-store.ts | 84 +++ .../orchestration-idle-mail-delivery.spec.ts | 406 +++++++++++ .../orchestration-idle-mail-restore.spec.ts | 190 +++++ 8 files changed, 1803 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/helpers/orchestration-mail-pane-agent.ts create mode 100644 tests/e2e/helpers/orchestration-mail-store.ts create mode 100644 tests/e2e/orchestration-idle-mail-delivery.spec.ts create mode 100644 tests/e2e/orchestration-idle-mail-restore.spec.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d46b6e6bd46..cabbb9ac044 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -33254,6 +33254,685 @@ describe('OrcaRuntimeService', () => { await task }) + it('delivers pending mail via notifyMessageArrived when the recipient is already idle', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'after wait' + }) + + // Why: notifyMessageArrived is the send-path hook; it must push-on-idle + // without requiring another agent-status transition (#12536). + runtime.notifyMessageArrived(terminal.handle, 'status') + + // The push is deferred one microtask so it lands behind any resolved check. + await Promise.resolve() + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(message.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not inject pending mail on notify when the recipient is still working', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'while working' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + // Why: busy must leave the row undelivered so a later idle can push it; + // a stamp-without-write would suppress later delivery (#12584 CodeRabbit). + expect(message.delivered_at).toBeNull() + db.close() + }) + + it('delivers on a first live idle frame that follows a seeded idle with no transition', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'restored idle' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + write.mockClear() + + // Why no working frame: a resumed agent sitting at its prompt emits an + // already-idle title first. The seed left lastAgentStatus 'idle', so there + // is no transition — only the liveness edge can release the row (#12536). + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 100) + + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: restored idle')) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not push on a cold-restore seeded idle status with no live observation', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + // Why: the persisted title is historical — the agent may have gone busy + // across the relaunch, so a seeded 'idle' must not authorize a PTY write. + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex done' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'seeded idle' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + + // The first live idle frame authorizes it and the row still delivers. + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: seeded idle')) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('lets a resolved check consume its rows before a later same-tick notify pushes', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: resolveMessageWaiter removes the waiter synchronously, but the check + // handler marks its rows read a microtask later. Two sends resuming off one + // shared in-flight promise put a no-waiter notify inside that window, so the + // push must not inject rows the resolved check is about to return. + const consumed = runtime + .waitForMessage(terminal.handle, { timeoutMs: 5_000 }) + .then(() => db.getUnreadMessages(terminal.handle).map((row) => (row.read = 1))) + const first = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'pulled' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + const second = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'also pulled' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + + await consumed + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + expect(first.delivered_at).toBeNull() + expect(second.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('leaves rows a live filtered waiter reserved out of the pushed batch', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: the push reads every pending row, not just the one that woke it. A + // `status` notify is unclaimed and pushes, but the worker_done row landing + // in the same drain belongs to this waiter's check — injecting it too would + // deliver that completion twice (pane + check return). + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['worker_done'], + timeoutMs: 5_000 + }) + const status = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'unclaimed status', + type: 'status' + }) + runtime.notifyMessageArrived(terminal.handle, 'status') + const done = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'reserved completion', + type: 'worker_done' + }) + runtime.notifyMessageArrived(terminal.handle, 'worker_done') + + await expect(waitPromise).resolves.toBe('notified') + await vi.advanceTimersByTimeAsync(600) + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) + expect(payloads.some((data) => data.includes('Subject: reserved completion'))).toBe(false) + expect(status.delivered_at).not.toBeNull() + expect(done.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('skips rows claimed by a waiter that registered after the notify', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'claimed late', + type: 'status' + }) + // Why: the notify snapshot is empty — no waiter existed yet. A check that + // blocks before the deferred push runs still owns this row, so only the + // push-time read of live waiters can keep it out of the pane. + runtime.notifyMessageArrived(terminal.handle, 'status') + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['status'], + timeoutMs: 5_000 + }) + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + expect(message.delivered_at).toBeNull() + + await vi.advanceTimersByTimeAsync(5_000) + await expect(waitPromise).resolves.toBe('timed_out') + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not carry pty-record live authority into a rebuilt leaf after a same-id respawn', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + // Why a UUID leaf id: the retirement fence's pty-candidate clause compares + // parsePaneKey(pty.paneKey).leafId to the republished leafId, and a non-UUID + // id falls back to `tabId:paneRuntimeId`, so it is always fenced after exit. + const leafId = '11111111-1111-1111-8111-111111111111' + const syncUuidLeaf = (): void => { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: null + } + ] + }) + } + syncUuidLeaf() + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + runtime.onPtyExit('pty-1', 0) + runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false }) + // Drop the leaf, then republish it: the rebuilt record's tailSource is the + // PTY record rather than the previous leaf, which is what pins the clear + // onPtyExit applies at the pty level. + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + syncUuidLeaf() + + const leaves = ( + runtime as unknown as { + leaves: Map< + string, + { lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > + } + ).leaves + expect(leaves.size).toBeGreaterThan(0) + const rebuilt = [...leaves.values()][0] + expect(rebuilt.lastAgentStatus).toBe('idle') + expect(rebuilt.lastAgentStatusObservedLive).toBe(false) + + setInMemoryOrchestrationMessages(runtime, db) + const [republished] = (await runtime.listTerminals()).terminals + const message = db.insertMessage({ + from: 'sender', + to: republished.handle, + subject: 'rebuilt leaf' + }) + runtime.notifyMessageArrived(republished.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('keeps live idle authority across a renderer graph republish', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: syncWindowGraph rebuilds every leaf record on any pane/tab change. + // An idle agent emits no new title, so dropping the live-status carry here + // would strand mail until the next OSC frame — the #12536 symptom. + syncSinglePty(runtime) + + const [republished] = (await runtime.listTerminals()).terminals + const message = db.insertMessage({ + from: 'sender', + to: republished.handle, + subject: 'after republish' + }) + + runtime.notifyMessageArrived(republished.handle, 'status') + await Promise.resolve() + + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: after republish') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not reuse the dead process live idle authority after a same-id respawn', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + write.mockClear() + + // Why: a cold restore respawns under the same session id and makes the + // leaf writable again before any new title. The dead process's live idle + // must not authorize typing into its replacement mid-turn. + runtime.onPtyExit('pty-1', 0) + runtime.onPtySpawned('pty-1', undefined, { awaitsRegistration: false }) + setInMemoryOrchestrationMessages(runtime, db) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'after same id respawn' + }) + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).toBeNull() + + // The replacement's first live idle frame re-authorizes delivery — with no + // working frame, since exit keeps lastAgentStatus 'idle' for `ps` and the + // replacement can come up straight at an idle prompt (no transition). + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: after same id respawn') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('pushes to an idle pane when the only live waiter filters out the message type', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + + // Why: a `check --wait --types worker_done` waiter never returns a status + // row — check re-reads under the same filter — so it is not the consumer + // and treating it as one would strand the message (#12536). + const waitPromise = runtime.waitForMessage(terminal.handle, { + typeFilter: ['worker_done'], + timeoutMs: 5_000 + }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'unfiltered status', + type: 'status' + }) + write.mockClear() + + runtime.notifyMessageArrived(terminal.handle, 'status') + + await Promise.resolve() + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: unfiltered status') + ) + await vi.advanceTimersByTimeAsync(600) + expect(message.delivered_at).not.toBeNull() + + // The filtered waiter stays blocked; the push did not consume its wake. + await vi.advanceTimersByTimeAsync(5_000) + await expect(waitPromise).resolves.toBe('timed_out') + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('resolves a registered waiter without PTY-injecting when the leaf is already idle', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const message = db.insertMessage({ + from: 'sender', + to: terminal.handle, + subject: 'for check wait' + }) + write.mockClear() + + // Why: blocked orchestration.check --wait is an explicit pull; push must + // not stamp delivered_at or type into the pane (double delivery, #12584). + const waitPromise = runtime.waitForMessage(terminal.handle, { timeoutMs: 5_000 }) + runtime.notifyMessageArrived(terminal.handle, 'status') + + await expect(waitPromise).resolves.toBe('notified') + expect(write).not.toHaveBeenCalled() + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('does not re-inject the same message when notify fires again during Enter delay', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'once only' }) + + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + + const payloadWrites = write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('Subject: once only') + ) + expect(payloadWrites).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(500) + const enterWrites = write.mock.calls.filter(([, payload]) => payload === '\r') + expect(enterWrites).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('delivers a second message parked during Enter delay once the flight settles', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' }) + const first = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'first' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + // Why the flush: the deferred push must actually arm its flight before the + // second message arrives, or this exercises a plain batch instead. + await Promise.resolve() + + // Why: mid-flight notify parks the leaf; flight settle re-runs delivery + // so the second row is not lost and is not double-injected with the first. + const second = db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'second' }) + runtime.notifyMessageArrived(terminal.handle, 'status') + await Promise.resolve() + expect( + write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ) + ).toHaveLength(0) + expect(second.delivered_at).toBeNull() + + // Why: release must not require another agent-status OSC — only the + // delayed-Enter flight timer. Advancing 3s with no status output covers + // timer-only settle (CodeRabbit settling-timeout gap, #12584). + await vi.advanceTimersByTimeAsync(3_000) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(first.delivered_at).not.toBeNull() + expect( + write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ) + ).toHaveLength(1) + expect(second.delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5426f28d97c..ea146b52380 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1214,6 +1214,10 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { // full tail. See computeTerminalTailWaitState. tailWaitState?: TerminalTailWaitState lastAgentStatus: AgentStatus | null + // Why: seeded status is a historical title replayed on restore, so it cannot + // authorize a PTY write. Only a live OSC observation sets this true; push + // delivery reads it so a cold-restored `idle` never types into a working agent. + lastAgentStatusObservedLive: boolean // Why: the most recent OSC title observed on this leaf's PTY data. Used by // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) // still recompute working/idle from the live title each call instead of @@ -1253,6 +1257,8 @@ type RuntimePtyWorktreeRecord = { disconnectedAt: number | null lastExitCode: number | null lastAgentStatus: AgentStatus | null + /** False until a live OSC frame sets the status; restore seeds never set it. */ + lastAgentStatusObservedLive: boolean lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, @@ -1976,6 +1982,24 @@ type MessageWaiter = { export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' +// Why: an unfiltered waiter claims every type. A row a live waiter will return +// from orchestration.check must not also be pushed into the pane — check reads +// by `read`, push stamps `delivered_at`, so neither hides the row from the other. +function messageTypeHasLiveWaiter( + waiters: Set | undefined, + messageType: string +): boolean { + if (!waiters) { + return false + } + for (const waiter of waiters) { + if (!waiter.typeFilter || waiter.typeFilter.includes(messageType)) { + return true + } + } + return false +} + function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -5499,6 +5523,7 @@ export class OrcaRuntimeService { preview: tailSource?.preview ?? '', waitBlockedAt: tailSource?.waitBlockedAt ?? null, lastAgentStatus: tailSource?.lastAgentStatus ?? null, + lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, lastOscTitle: tailSource?.lastOscTitle ?? null, lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, paneTitleUpdatedAt: @@ -10229,6 +10254,7 @@ export class OrcaRuntimeService { pty.lastOscTitleAt = observedAt pty.lastOscTitleEpochMs = Date.now() pty.lastAgentStatus = agentStatus + pty.lastAgentStatusObservedLive = true this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus if (agentStatus === 'idle' && prevStatus !== 'idle') { @@ -10266,6 +10292,7 @@ export class OrcaRuntimeService { leaf.lastOscTitle = normalizedTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() const prevStatus = leaf.lastAgentStatus + const prevObservedLive = leaf.lastAgentStatusObservedLive // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous @@ -10274,6 +10301,7 @@ export class OrcaRuntimeService { // exits would observe the cleared value, and they correctly fall // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus + leaf.lastAgentStatusObservedLive = true // Why: resolve tui-idle on any transition TO idle (not just working→idle). // Claude Code may skip "working" entirely on fast tasks, going null→idle, // and the coordinator's tui-idle waiter would hang forever waiting for a @@ -10282,6 +10310,14 @@ export class OrcaRuntimeService { // which isn't a task-completion signal. if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolveTuiIdleWaiters(leaf) + } + // Why the second condition: push delivery is gated on LIVE idle, so its + // authorizing edge is liveness as well as status. A restore seed or a + // status kept across a same-id respawn leaves a stale 'idle' behind, and + // an agent whose first live title is already idle (claude --resume at its + // prompt) then shows no transition — the row would strand, which is + // exactly #12536. Waiter semantics stay transition-only above. + if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { this.deliverPendingMessages(leaf) } } @@ -10311,6 +10347,9 @@ export class OrcaRuntimeService { pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null pty.lastAgentStatus = null + // Why: the prior process's live frames say nothing about the replacement, + // so the seed a same-id restore applies must not inherit its authority. + pty.lastAgentStatusObservedLive = false pty.managementTitle = null pty.managementTitleAt = null } @@ -10318,6 +10357,7 @@ export class OrcaRuntimeService { leaf.lastOscTitle = null leaf.lastOscTitleAt = null leaf.lastAgentStatus = null + leaf.lastAgentStatusObservedLive = false } this.clearAgentRowSnapshotsForPty(ptyId) } @@ -11199,10 +11239,11 @@ export class OrcaRuntimeService { // Why: seed-derived agent status reflects historical state. Orchestration // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react - // to LIVE transitions, so this helper writes leaf.lastAgentStatus only and - // never resolves waiters. detectAgentStatusFromTitle wrap mirrors the live - // path so seeded and live values are the same union member, keeping - // downstream `=== 'idle'` checks correct. + // to LIVE transitions, so this helper writes leaf.lastAgentStatus only, + // leaves lastAgentStatusObservedLive untouched, and never resolves waiters. + // detectAgentStatusFromTitle wrap mirrors the live path so seeded and live + // values are the same union member, keeping downstream `=== 'idle'` checks + // correct. private applySeededAgentStatus(ptyId: string, title: string): void { if (!title) { return @@ -13470,6 +13511,11 @@ export class OrcaRuntimeService { this.setPairedRendererSessionOwnership(pty.ptyId, false) pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode + // Why: the exited process's live frames say nothing about a replacement. + // A same-id respawn makes the leaf writable again before any new title, + // so leaving this true would let push delivery type into the new process + // on the dead one's idle. lastAgentStatus itself stays for `ps` display. + pty.lastAgentStatusObservedLive = false this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } @@ -13487,6 +13533,7 @@ export class OrcaRuntimeService { leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode + leaf.lastAgentStatusObservedLive = false this.resolveExitWaiters(leaf) if (!preservesAbnormalSshSurface) { this.failActiveDispatchOnExit(leaf, exitCode) @@ -28894,6 +28941,7 @@ export class OrcaRuntimeService { disconnectedAt: state.connected === false ? Date.now() : null, lastExitCode: null, lastAgentStatus: null, + lastAgentStatusObservedLive: false, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, @@ -31069,11 +31117,23 @@ export class OrcaRuntimeService { return this.getLeavesForPty(ptyId)[0] ?? null } - deliverPendingMessagesForHandle(handle: string): void { + deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { + // Why before the try: `dispatch:`/`run:` mailbox addresses are never terminal + // handles, and federation sync notifies once per relayed item — letting each + // one build and discard a `terminal_handle_stale` Error (stack capture) is + // pure waste. getLiveLeafForHandle would reject them on the same lookup. + if (!this.handles.has(handle)) { + return + } try { const { leaf } = this.getLiveLeafForHandle(handle) - if (leaf.lastAgentStatus === 'idle') { - this.deliverPendingMessages(leaf) + // Why lastAgentStatusObservedLive: a cold restore seeds `idle` from the + // title persisted at snapshot time, so an agent that went busy across the + // relaunch still reads idle until its first live frame. Pushing on that + // would type a message plus Enter into a working agent and stamp the row + // delivered. Seeded state waits for a live observation to authorize it. + if (leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive) { + this.deliverPendingMessages(leaf, false, reservedTypes) } } catch { // Unknown/stale handles can't be pushed now; the persisted message stays available via explicit check or future idle delivery. @@ -31082,15 +31142,44 @@ export class OrcaRuntimeService { // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. notifyMessageArrived(handle: string, messageType?: string): void { + // Why: push-on-idle is driven by status transitions; a message that + // arrives while the recipient is already idle never sees a transition, so + // deliver now (#12536). deliverPendingMessagesForHandle no-ops when the + // leaf is not idle. Main's messageDeliveryFlights serialize mid-Enter + // re-notifies without a separate settle barrier. + // Why skip when a waiter will consume this: deliverPendingMessages stamps + // delivered_at but not read, so a blocked orchestration.check --wait would + // still re-read the row and the pane would also receive it (double + // delivery). The pull wins; the push stays pending for a later notify. + // Why "will consume" and not "exists": a waiter filtered to other types + // never returns this row — check re-reads under the same filter on timeout + // — so treating it as the consumer strands the message in exactly the + // already-idle state #12536 is about. const waiters = this.messageWaitersByHandle.get(handle) - if (!waiters || waiters.size === 0) { + // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. + const consumers = waiters + ? [...waiters].filter( + (waiter) => !messageType || !waiter.typeFilter || waiter.typeFilter.includes(messageType) + ) + : [] + if (consumers.length === 0) { + // Why snapshot the reservation here: every remaining waiter filters this + // type out, but the push reads ALL pending rows. A waiter resolved later in + // this same drain is gone by the time the push runs, so reading waiters + // then would miss what its check is about to return. Captured now, the + // types those waiters claim stay out of the batch. + const reservedTypes = new Set(waiters ? [...waiters].flatMap((w) => w.typeFilter ?? []) : []) + // Why queueMicrotask: resolveMessageWaiter removes the waiter synchronously + // but its check handler marks the rows read a microtask later. Two sends + // that resumed adjacently off one shared in-flight promise (group send + // awaits listTerminals) put a no-waiter notify inside that window, where a + // synchronous push would inject rows the resolved check is about to return. + // Deferring one hop puts the push behind any already-queued check, and it + // re-reads undelivered rows when it runs so nothing strands. + queueMicrotask(() => this.deliverPendingMessagesForHandle(handle, reservedTypes)) return } - for (const waiter of [...waiters]) { - // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. - if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { - continue - } + for (const waiter of consumers) { this.resolveMessageWaiter(waiter, 'notified') } } @@ -31720,7 +31809,11 @@ export class OrcaRuntimeService { } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. - private deliverPendingMessages(leaf: RuntimeLeafRecord, skipAbsenceProbe = false): void { + private deliverPendingMessages( + leaf: RuntimeLeafRecord, + skipAbsenceProbe = false, + reservedTypes?: ReadonlySet + ): void { if (!this._orchestrationDb) { return } @@ -31736,7 +31829,18 @@ export class OrcaRuntimeService { return } - const unread = this._orchestrationDb.getUndeliveredUnreadMessages(handle) + // Why filter here and not at the trigger: the push reads every pending row, + // not just the one that woke it, so a row a pull has claimed would be typed + // into the pane AND returned by that pull's check. Live waiters cover the + // still-blocked case; reservedTypes carries the notify-time snapshot for a + // waiter resolved later in the same drain, which is already gone from the map. + const waiters = this.messageWaitersByHandle.get(handle) + const unread = this._orchestrationDb + .getUndeliveredUnreadMessages(handle) + .filter( + (message) => + !reservedTypes?.has(message.type) && !messageTypeHasLiveWaiter(waiters, message.type) + ) if (unread.length === 0) { return } @@ -31766,7 +31870,30 @@ export class OrcaRuntimeService { .then((absent) => { this.probeDeferredDeliveryPtyIds.delete(probedPtyId) if (!absent && leaf.ptyId === probedPtyId) { - this.deliverPendingMessages(leaf, true) + // Why a macrotask and not the stale reservation snapshot: a `remote:` + // pty answers the probe null before its first await, so this chain can + // settle in microtasks and overtake the resumption of a check resolved + // meanwhile — that check's waiter is already out of the map and its + // rows are not yet read, so the push would inject what it returns. + // Yielding the turn lets every queued check mark its rows read first; + // re-reading then (rather than replaying a reservation this probe may + // have outlived) is what keeps an orphaned row from stranding. + setTimeout(() => { + // Why current state, not the closure: the gate that authorized this + // push ran before the probe. A same-id cold restore inside the probe + // window keeps ptyId identical and makes the leaf writable again, so + // an id-only check would type the payload plus Enter into a process + // whose idle was never observed — and stamp the row delivered, which + // loses it. Re-read the leaf and re-apply the live-idle gate. + const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if ( + currentLeaf?.ptyId === probedPtyId && + currentLeaf.lastAgentStatus === 'idle' && + currentLeaf.lastAgentStatusObservedLive + ) { + this.deliverPendingMessages(currentLeaf, true) + } + }, 0) } }) .catch(() => { diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index a90bbe80410..d9d91237d08 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -298,6 +298,8 @@ describe('orchestration RPC methods', () => { describe('orchestration.send', () => { it('sends a message', async () => { setup() + // Why: send notifies arrival so already-idle recipients get push-on-idle + // delivery without waiting for a status transition (#12536). vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) const result = (await call('orchestration.send', { from: 'term_coord', @@ -308,7 +310,7 @@ describe('orchestration RPC methods', () => { expect(result.message.id).toMatch(/^msg_/) expect(result.message.from_handle).toBe('term_coord') expect(result.message.run_id).toBe(activeRunId) - expect(runtime.deliverPendingMessagesForHandle).not.toHaveBeenCalled() + expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalled() }) it('routes exact Dispatch mail independently of terminal handles', async () => { diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 01aed591d79..823c04e5a6d 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -243,7 +243,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { return { rows, markAsDelivered, - insert(subject: string): void { + insert(subject: string, type: StoredMessageRow['type'] = 'status'): void { rows.push({ id: `msg_${rows.length + 1}`, run_id: 'run_test', @@ -251,7 +251,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { to_handle: toHandle(), subject, body: '', - type: 'status', + type, priority: 'normal', thread_id: null, payload: null, @@ -263,8 +263,9 @@ function makeOrchestrationDbStub(toHandle: () => string) { }) }, db: { + // Mirrors the real query: `read = 0 AND delivered_at IS NULL`. getUndeliveredUnreadMessages: (handle: string) => - rows.filter((row) => row.to_handle === handle && !row.delivered_at), + rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at), getActiveCoordinatorRun: () => null, // Consulted by onPtyExit's dispatch-failure path. getActiveDispatchForTerminal: () => null, @@ -289,6 +290,131 @@ describe('push-on-idle orchestration delivery absence gate', () => { return { runtime, handle, write, stub } } + // Why: the gate that authorizes a push runs BEFORE the probe defers, so a + // same-id cold restore inside the probe window would otherwise be written to on + // the dead process's authority — ptyId is exactly what a same-id respawn keeps. + it('re-applies the live-idle gate when the probe answers after a same-id respawn', async () => { + let resolveProbe!: (value: boolean | null) => void + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: () => + new Promise((resolve) => { + resolveProbe = resolve + }) + }) + stub.insert('for the old session') + + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + // The session dies and cold-restores under the same id while the probe is out. + runtime.onPtyExit(STALE_PTY_ID, 0) + runtime.onPtySpawned(STALE_PTY_ID, undefined, { awaitsRegistration: false }) + + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(write).not.toHaveBeenCalled() + expect(stub.rows[0].delivered_at).toBeNull() + + // The replacement's own live idle frame releases the row — through a fresh + // probe, since this leaf's pty is still unknown to the provider. + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 200) + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(write).toHaveBeenCalledWith( + STALE_PTY_ID, + expect.stringContaining('Subject: for the old session') + ) + }) + + // Why: a `remote:` pty answers probePtyLiveness with null before its first + // await (ipc/pty.ts), so the probe settles on a pure microtask chain. Without a + // macrotask hop the continuation runs BEFORE the resumption of a check resolved + // in the meantime — the waiter is already out of the map and its rows are not + // yet read, so the push injects exactly what that check is about to return. + it('waits a macrotask before delivering so a resolved check consumes its rows first', async () => { + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: async () => null + }) + + const pulled: string[] = [] + const checkResumed = runtime + .waitForMessage(handle, { typeFilter: ['worker_done'], timeoutMs: 60_000 }) + .then(() => { + for (const row of stub.rows) { + if (row.type === 'worker_done' && row.read === 0) { + row.read = 1 + pulled.push(row.subject) + } + } + }) + + stub.insert('unclaimed status') + runtime.notifyMessageArrived(handle, 'status') + // Land the completion while the probe chain is mid-flight — the slot where + // the continuation would otherwise overtake the check's resumption. + await Promise.resolve() + await Promise.resolve() + stub.insert('worker completion', 'worker_done') + runtime.notifyMessageArrived(handle, 'worker_done') + + await checkResumed + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(pulled).toEqual(['worker completion']) + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) + expect(payloads.some((data) => data.includes('Subject: worker completion'))).toBe(false) + }) + + // Why: the notify-time reservation snapshot exists for a waiter resolved inside + // one microtask drain. The probe continuation runs many macrotasks later, and + // the probe dedup swallows every notify arriving meanwhile — so a reservation + // carried in here would skip a row with nothing left to retry it (#12536 again). + it('does not carry a stale waiter reservation into the probe continuation', async () => { + let resolveProbe!: (value: boolean | null) => void + const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ + probePtyLiveness: () => + new Promise((resolve) => { + resolveProbe = resolve + }) + }) + + const waitPromise = runtime.waitForMessage(handle, { + typeFilter: ['worker_done'], + timeoutMs: 60_000 + }) + stub.insert('unclaimed status') + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + // The reserving waiter goes away, then its type finally arrives — and the + // probe dedup drops this notify, so only the continuation can deliver it. + runtime.cancelMessageWaiters(handle) + await expect(waitPromise).resolves.toBe('cancelled') + stub.insert('late completion', 'worker_done') + runtime.notifyMessageArrived(handle, 'worker_done') + await Promise.resolve() + + resolveProbe(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Why twice: the probe continuation yields a turn before delivering. + await new Promise((resolve) => setTimeout(resolve, 0)) + + const payloads = write.mock.calls + .map(([, data]) => data) + .filter((data): data is string => typeof data === 'string') + expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) + expect(payloads.some((data) => data.includes('Subject: late completion'))).toBe(true) + }) + it('keeps messages queued instead of marking a proven-absent pty delivered', async () => { const { runtime, handle, write, stub } = await makeIdleLeafWithoutPtyRecord({ probePtyLiveness: async () => false @@ -311,6 +437,8 @@ describe('push-on-idle orchestration delivery absence gate', () => { runtime.deliverPendingMessagesForHandle(handle) await new Promise((resolve) => setTimeout(resolve, 0)) + // Why twice: the probe continuation yields a turn before delivering. + await new Promise((resolve) => setTimeout(resolve, 0)) expect(write).toHaveBeenCalledWith(STALE_PTY_ID, expect.stringContaining('Subject: hello')) }) @@ -435,8 +563,17 @@ describe('push-on-idle orchestration delivery absence gate', () => { expect(stub.markAsDelivered).not.toHaveBeenCalled() expect(stub.rows[0].delivered_at).toBeNull() - // The replacement's own delivery starts a fresh flight and completes. + // The replacement's own delivery starts a fresh flight and completes — + // but only once ITS live title proves idle; the dead session's live status + // no longer authorizes a write into the new process. runtime.deliverPendingMessagesForHandle(handle) + expect( + write.mock.calls.filter( + ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ) + ).toHaveLength(1) + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex working\x07', 200) + runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 201) const payloadWrites = write.mock.calls.filter( ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') ) diff --git a/tests/e2e/helpers/orchestration-mail-pane-agent.ts b/tests/e2e/helpers/orchestration-mail-pane-agent.ts new file mode 100644 index 00000000000..f948bfa8c25 --- /dev/null +++ b/tests/e2e/helpers/orchestration-mail-pane-agent.ts @@ -0,0 +1,157 @@ +/** + * A scriptable stand-in for an agent CLI, for orchestration push-delivery E2E. + * + * Why a purpose-built process and not a bare shell emitting titles: push-on-idle + * is gated on the status Orca infers from live OSC titles and delivers by + * writing into the pane's foreground process. A shell echoes rather than + * records, so it can prove the gate but never the payload. This process owns + * both sides — the test drives its title through a control file and it appends + * every stdin chunk to a ledger, which is what makes "the banner and the Enter + * reached the agent" an assertion instead of an inference. + * + * Titles come from a polled file, not stdin, because orchestration writes to + * stdin itself; a stdin control channel could not tell a test command apart from + * the delivery under test. + * + * Why it runs in the pane the fixture already opened, rather than a pane created + * for it: terminal.create waits up to 10s for a renderer graph sync to bind the + * new tab's handle, and a headless CI renderer misses that deadline — every spec + * here died on 'Timed out waiting for terminal handle after creation'. Nothing + * on the delivery path reads a pane's agent metadata (it resolves the leaf, the + * OSC title, and PTY liveness), so a foreground process in a mounted pane + * exercises the same code with none of that startup race. + */ +import { mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +/** `detectAgentStatusFromTitle` reads these as agent-name + strong keyword. */ +export const CODEX_IDLE_TITLE = 'Codex done' +export const CODEX_WORKING_TITLE = 'Codex working' +/** Also satisfies `isCursorAgentTitle`, which suppresses the synthesized Enter. */ +export const CURSOR_IDLE_TITLE = 'Cursor Ready' + +export type AgentLedgerEntry = { + pid: number + at: number + event: 'start' | 'stdin' | 'title' + data?: string + title?: string +} + +const AGENT_SOURCE = ` +const { appendFileSync, existsSync, readFileSync, statSync } = require('node:fs') + +const [ledgerPath, controlPath] = process.argv.slice(2) + +function log(entry) { + try { + appendFileSync(ledgerPath, JSON.stringify({ pid: process.pid, at: Date.now(), ...entry }) + '\\n') + } catch {} +} + +log({ event: 'start' }) + +// Raw mode is what every agent TUI does, and it is load-bearing here: a cooked +// PTY applies ICRNL, so the synthesized Enter would arrive as \\n and be +// indistinguishable from the banner's own newlines. +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} + +// Every byte orchestration pushes lands here — banner text and Enter alike. +process.stdin.on('data', (chunk) => log({ event: 'stdin', data: chunk.toString() })) +process.stdin.resume() + +// No title is emitted until the test asks for one, so a pane can be held in the +// "no live agent status yet" state some cases depend on. Keyed on mtime rather +// than content so a test can re-emit the SAME title: proving a restored pane +// needed a LIVE frame means sending an idle it already appears to have. +let lastStamp = null +setInterval(() => { + if (!existsSync(controlPath)) return + let title + let stamp + try { + stamp = statSync(controlPath).mtimeMs + if (stamp === lastStamp) return + title = readFileSync(controlPath, 'utf8').trim() + } catch { + return + } + if (!title) return + lastStamp = stamp + process.stdout.write('\\u001b]0;' + title + '\\u0007') + log({ event: 'title', title }) +}, 50) + +setInterval(() => {}, 60_000) +` + +export type MailPaneAgent = { + /** Shell-agnostic command that starts the agent; no trailing carriage return. */ + launchCommand: string + /** Emit `title` as an OSC title from the live process. */ + setTitle: (title: string) => void + readLedger: () => AgentLedgerEntry[] + /** Concatenated stdin — what the agent actually received. */ + readStdin: () => string + hasStarted: () => boolean + /** Emitted-title count; the readiness signal when a title is re-sent as-is. */ + titleEmitCount: () => number +} + +// Why worker exit and not a spec's afterAll: Playwright reuses a worker across +// spec files, and a temp dir removed while another spec still polls its ledger +// surfaces as an agent that mysteriously stopped reporting. +const agentDirs: string[] = [] +process.once('exit', () => { + for (const dir of agentDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** One isolated agent: its own script copy, ledger, and control file. */ +export function createMailPaneAgent(): MailPaneAgent { + const dir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-mail-agent-')) + agentDirs.push(dir) + const scriptPath = path.join(dir, 'agent.cjs') + const ledgerPath = path.join(dir, 'ledger.jsonl') + const controlPath = path.join(dir, 'title') + writeFileSync(scriptPath, AGENT_SOURCE) + writeFileSync(ledgerPath, '') + + // Why forward slashes: valid for node on Windows and parsed identically by + // PowerShell, cmd, and POSIX shells, where raw backslashes would be eaten. + const quote = (value: string): string => `"${value.replaceAll('\\', '/')}"` + + const readLedger = (): AgentLedgerEntry[] => { + if (!existsSync(ledgerPath)) { + return [] + } + return readFileSync(ledgerPath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as AgentLedgerEntry] + } catch { + // A torn final line just means the agent is mid-append; the poll retries. + return [] + } + }) + } + + return { + launchCommand: `node ${quote(scriptPath)} ${quote(ledgerPath)} ${quote(controlPath)}`, + setTitle: (title: string) => writeFileSync(controlPath, title), + readLedger, + readStdin: () => + readLedger() + .filter((entry) => entry.event === 'stdin') + .map((entry) => entry.data ?? '') + .join(''), + hasStarted: () => readLedger().some((entry) => entry.event === 'start'), + titleEmitCount: () => readLedger().filter((entry) => entry.event === 'title').length + } +} diff --git a/tests/e2e/helpers/orchestration-mail-store.ts b/tests/e2e/helpers/orchestration-mail-store.ts new file mode 100644 index 00000000000..520498dad69 --- /dev/null +++ b/tests/e2e/helpers/orchestration-mail-store.ts @@ -0,0 +1,84 @@ +/** + * Direct reads of the orchestration mailbox for E2E assertions. + * + * Why read SQLite instead of `orchestration.check`: check is itself a consumer — + * it marks rows read and backfills `delivered_at` — so using it to observe would + * destroy the very distinction these specs exist to test. The two markers are + * independent on purpose: `delivered_at` means a push typed the row into a pane, + * `read` means a pull consumed it. Only an out-of-band read can tell them apart. + */ +import path from 'node:path' +import Database from '../../../src/main/sqlite/sync-database' + +export type MailRow = { + id: string + type: string + to_handle: string + subject: string + read: number + delivered_at: string | null +} + +export type MailDisposition = 'pending' | 'pushed' | 'pulled' + +function withMailDb(userDataDir: string, read: (db: Database) => T): T { + const db = new Database(path.join(userDataDir, 'orchestration.db')) + try { + return read(db) + } finally { + db.close() + } +} + +export function readMailRow(userDataDir: string, id: string): MailRow | undefined { + return withMailDb(userDataDir, (db) => + db + .prepare('SELECT id, type, to_handle, subject, read, delivered_at FROM messages WHERE id = ?') + .get(id) + ) as MailRow | undefined +} + +export function readMailbox(userDataDir: string, toHandle: string): MailRow[] { + return withMailDb(userDataDir, (db) => + db + .prepare( + 'SELECT id, type, to_handle, subject, read, delivered_at FROM messages WHERE to_handle = ? ORDER BY sequence' + ) + .all(toHandle) + ) as MailRow[] +} + +/** + * Mark `handle` as the running coordinator — the state that makes push delivery + * withhold the synthesized Enter, because that prompt holds user-typed input. + * + * Why seed the row instead of calling `orchestration.run`: that RPC also starts + * a live coordinator loop which dispatches workers on a timer, and its + * scheduling would race every assertion here. The carve-out reads nothing but + * this row. + */ +export function startCoordinatorRun(userDataDir: string, handle: string): void { + withMailDb(userDataDir, (db) => { + db.prepare( + `INSERT INTO coordinator_runs (id, spec, status, coordinator_handle) + VALUES (?, 'e2e coordinator Enter carve-out', 'running', ?)` + ).run(`e2e-coordinator-${handle}`, handle) + }) +} + +/** + * How a row was consumed, if at all. + * + * `read` is checked first because a pull backfills `delivered_at` via COALESCE, + * so a pulled row also carries a delivery stamp — the stamp alone cannot prove + * a push happened. + */ +export function mailDisposition(row: MailRow | undefined): MailDisposition | 'missing' { + if (!row) { + return 'missing' + } + if (row.read === 1) { + return 'pulled' + } + return row.delivered_at === null ? 'pending' : 'pushed' +} diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts new file mode 100644 index 00000000000..2d1ec41b6bf --- /dev/null +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -0,0 +1,406 @@ +/** + * Push-on-idle mail delivery, end to end (#12536). + * + * Orchestration hands a message to an agent one of two ways: a supervised agent + * pulls with `orchestration.check --wait`, and an unsupervised one has the text + * typed into its pane when the runtime sees it go idle. The push half was driven + * only by a busy→idle transition, so mail that arrived while the recipient was + * ALREADY idle waited for a transition that never came and sat unread forever. + * + * These specs drive real PTYs: the recipient is a fake `codex` on PATH whose OSC + * titles the test controls through a file, and which appends every stdin chunk + * to a ledger. That ledger is the oracle — it proves the banner and the + * synthesized Enter reached the agent process, which no store or DB read can. + * + * The ordering fixes on this path (microtask deferral, probe-window respawn, + * waiter reservations) are sub-millisecond races that E2E cannot steer; they are + * covered in src/main/runtime/orca-runtime.test.ts. What lives here is every + * behavior that needs a real process, a real title, or a real pane. + */ +import { test, expect } from './helpers/orca-app' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' +import { + CODEX_IDLE_TITLE, + CODEX_WORKING_TITLE, + CURSOR_IDLE_TITLE, + createMailPaneAgent, + type MailPaneAgent +} from './helpers/orchestration-mail-pane-agent' +import { + mailDisposition, + readMailRow, + startCoordinatorRun +} from './helpers/orchestration-mail-store' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +/** The wrapper `formatMessagesForInjection` puts around every pushed batch. */ +const BANNER_PREFIX = '--- Orchestration Messages' + +// Why generous: the push runs a microtask behind the send, may defer once more +// behind a liveness probe, and only stamps delivered_at after a 500ms Enter. +const DELIVERY_TIMEOUT_MS = 20_000 +// Why 3s: long enough to cover that same chain, so "still pending" means the +// gate refused rather than that the push had not run yet. +const NO_DELIVERY_SETTLE_MS = 3_000 + +type AgentPane = { + handle: string + agent: MailPaneAgent + ptyId: string +} + +type MailFixture = { + client: RuntimeClient + userDataDir: string + worktreeId: string + openAgentPane: () => Promise +} + +/** + * Why retry: Electron can recreate the evaluated main-world context during + * startup, which surfaces as a one-off 'Execution context was destroyed' rather + * than a real failure. Same guard as installTerminalPtyWriteSpy. + */ +async function readUserDataDir(electronApp: ElectronApplication): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await electronApp.evaluate(({ app }) => app.getPath('userData')) + } catch (error) { + const transient = + error instanceof Error && error.message.includes('Execution context was destroyed') + if (!transient || attempt >= 5) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } +} + +async function setUpMailFixture( + orcaPage: Page, + electronApp: ElectronApplication +): Promise { + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + + const userDataDir = await readUserDataDir(electronApp) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + + // Why: the renderer publishes the active worktree before the runtime finishes + // registering it, and terminal.create resolves its selector against the + // runtime — racing that yields selector_not_found, not a slow create. + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + return listed.result.worktrees.some((worktree) => worktree.id === worktreeId) + }, + { timeout: 60_000, message: 'runtime never registered the active worktree' } + ) + .toBe(true) + + const openAgentPane = async (): Promise => { + // The fixture's pane is already mounted, so its leaf exists — which is what + // push delivery resolves the write target through. + const ptyId = await waitForActivePanePtyId(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + const resolved = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey + }) + const handle = resolved.result.terminal.handle + + // Why prove the shell echoes first: keystrokes typed at a shell that has not + // reached its prompt are simply dropped, and the agent then never starts for + // a reason unrelated to anything under test. + await waitForPtyShellEcho(orcaPage, ptyId, 60_000) + const agent = createMailPaneAgent() + await execInTerminal(orcaPage, ptyId, agent.launchCommand) + await expect + .poll(() => agent.hasStarted(), { timeout: 60_000, message: 'agent never started' }) + .toBe(true) + return { handle, agent, ptyId } + } + + return { client, userDataDir, worktreeId, openAgentPane } +} + +/** Wait until the runtime has observed `title` as a LIVE frame from the pane. */ +async function waitForObservedTitle( + client: RuntimeClient, + handle: string, + title: string +): Promise { + await expect + .poll( + async () => { + const listed = await client.call('terminal.list') + return listed.result.terminals.find((entry) => entry.handle === handle)?.title ?? null + }, + { timeout: 30_000, message: `runtime never observed the title ${title}` } + ) + .toBe(title) +} + +/** Put the pane in the state #12536 is about: idle, observed live, no transition pending. */ +async function driveToLiveIdle(client: RuntimeClient, pane: AgentPane): Promise { + pane.agent.setTitle(CODEX_WORKING_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) + pane.agent.setTitle(CODEX_IDLE_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) +} + +async function sendMail( + client: RuntimeClient, + to: string, + overrides: { subject: string; type?: string; body?: string } +): Promise { + const sent = await client.call<{ message: { id: string } }>('orchestration.send', { + to, + from: 'e2e-sender', + subject: overrides.subject, + body: overrides.body ?? 'e2e body', + type: overrides.type ?? 'status' + }) + return sent.result.message.id +} + +async function expectPushed(pane: AgentPane, subject: string): Promise { + await expect + .poll(() => pane.agent.readStdin(), { + timeout: DELIVERY_TIMEOUT_MS, + message: 'banner never reached the agent process' + }) + .toContain(BANNER_PREFIX) + expect(pane.agent.readStdin()).toContain(`Subject: ${subject}`) +} + +/** + * The synthesized Enter is a separate write ~500ms after the banner. The banner + * itself is `\n`-joined, so a `\r` anywhere in stdin can only be that submit — + * which keeps the assertion independent of how the PTY chunks the two writes. + */ +async function expectSubmitted(pane: AgentPane): Promise { + await expect + .poll(() => pane.agent.readStdin().includes('\r'), { + timeout: DELIVERY_TIMEOUT_MS, + message: 'orchestration never synthesized Enter' + }) + .toBe(true) +} + +/** Inverse of expectSubmitted, for the panes whose submit stays user-owned. */ +function expectNotSubmitted(pane: AgentPane): void { + expect(pane.agent.readStdin()).not.toContain('\r') +} + +/** + * Why a fixed wait and not expect.poll: poll settles the instant the value + * matches, so polling for 'pending' would pass before the push had any chance + * to run and would assert nothing at all. The window has to elapse in full. + */ +async function expectStaysPending( + page: Page, + userDataDir: string, + pane: AgentPane, + messageId: string +): Promise { + // The row must exist first, or "pending" could just mean the send never landed. + expect(readMailRow(userDataDir, messageId)).toBeDefined() + await page.waitForTimeout(NO_DELIVERY_SETTLE_MS) + expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') + expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) +} + +test.describe('orchestration push-on-idle mail delivery', () => { + test('delivers mail that arrives while the agent is already idle', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + + // The regression: no busy→idle edge follows this send, so before #12536 the + // row stayed pending until something unrelated made the agent transition. + const subject = 'Already idle delivery' + const messageId = await sendMail(client, pane.handle, { subject }) + + await expectPushed(pane, subject) + await expectSubmitted(pane) + await expect + .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { + timeout: DELIVERY_TIMEOUT_MS + }) + .toBe('pushed') + }) + + test('holds mail while the agent is working and releases it on the idle frame', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + pane.agent.setTitle(CODEX_WORKING_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) + + const subject = 'Held while working' + const messageId = await sendMail(client, pane.handle, { subject }) + await expectStaysPending(orcaPage, userDataDir, pane, messageId) + + // Releasing the gate proves the silence above was the working status and not + // a harness that never wired the send to this pane at all. + pane.agent.setTitle(CODEX_IDLE_TITLE) + await expectPushed(pane, subject) + await expect + .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { + timeout: DELIVERY_TIMEOUT_MS + }) + .toBe('pushed') + }) + + // Guards the null→idle path rather than reproducing #12536: a fresh pane has + // no status, so idle IS a transition here. The no-transition variant needs a + // restore-seeded idle and lives in orchestration-idle-mail-restore.spec.ts. + test('delivers mail queued before a fresh agent has reported any status', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + + // No title at all yet — the pane has no live agent status, which is where a + // resumed agent sits before it paints its prompt. + const subject = 'First live idle frame' + const messageId = await sendMail(client, pane.handle, { subject }) + await expectStaysPending(orcaPage, userDataDir, pane, messageId) + + // Idle is this pane's FIRST live status, so there is no busy→idle edge here + // either; delivery has to hang off the liveness of the observation. + pane.agent.setTitle(CODEX_IDLE_TITLE) + await expectPushed(pane, subject) + await expectSubmitted(pane) + }) + + test('leaves the mail to a live waiter instead of pushing it into the pane', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + + // A supervised agent is parked in a long-poll. Pushing as well would deliver + // the same row twice — check consumes by `read` and push stamps + // `delivered_at`, so neither marker hides the row from the other. + // Why peek: this pane is bound to no Run, and that legacy mailbox refuses a + // consuming read. Peek still registers the same unfiltered waiter, which is + // what suppresses the push — the pull's own bookkeeping is not under test. + const waiting = client.call<{ messages: { subject: string }[] }>('orchestration.check', { + terminal: pane.handle, + peek: true, + wait: true, + timeoutMs: 30_000 + }) + // Why a settle: the waiter must be registered before the send, or the send + // correctly sees no consumer and this asserts the wrong branch. + await orcaPage.waitForTimeout(1_000) + + const subject = 'Waiter claims it' + const messageId = await sendMail(client, pane.handle, { subject }) + + const pulled = await waiting + expect(pulled.result.messages.map((message) => message.subject)).toContain(subject) + expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) + // Pending, not pushed: the pull won, and the push stays available for a + // later notify rather than racing this one. + expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') + }) + + test('pushes to the pane when the only waiter filters this message type out', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + + // A waiter scoped to worker_done never returns a status row, so treating it + // as this message's consumer would strand the row exactly as #12536 did. + const waiting = client + .call('orchestration.check', { + terminal: pane.handle, + types: 'worker_done', + wait: true, + timeoutMs: 8_000 + }) + .catch(() => undefined) + await orcaPage.waitForTimeout(1_000) + + const subject = 'Filtered waiter' + const messageId = await sendMail(client, pane.handle, { subject, type: 'status' }) + + await expectPushed(pane, subject) + await expect + .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { + timeout: DELIVERY_TIMEOUT_MS + }) + .toBe('pushed') + await waiting + }) + + test('writes the banner but never Enter for the active coordinator pane', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + startCoordinatorRun(userDataDir, pane.handle) + + // The coordinator prompt is user-owned input; synthesizing Enter there would + // submit whatever the human was mid-way through typing (#7337). + const subject = 'Coordinator no-submit' + await sendMail(client, pane.handle, { subject }) + + await expectPushed(pane, subject) + await orcaPage.waitForTimeout(2_000) + expectNotSubmitted(pane) + }) + + test('writes the banner but never Enter for a Cursor agent pane', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + // Cursor treats injected PTY text as editable prompt content, so submitting + // has to stay under user control there too. + pane.agent.setTitle(CURSOR_IDLE_TITLE) + await waitForObservedTitle(client, pane.handle, CURSOR_IDLE_TITLE) + + const subject = 'Cursor no-submit' + await sendMail(client, pane.handle, { subject }) + + await expectPushed(pane, subject) + await orcaPage.waitForTimeout(2_000) + expectNotSubmitted(pane) + }) +}) diff --git a/tests/e2e/orchestration-idle-mail-restore.spec.ts b/tests/e2e/orchestration-idle-mail-restore.spec.ts new file mode 100644 index 00000000000..6e38b9fd872 --- /dev/null +++ b/tests/e2e/orchestration-idle-mail-restore.spec.ts @@ -0,0 +1,190 @@ +/** + * Mail must survive a restart: never injected on restored state alone, always + * delivered once the agent speaks again (#12536). + * + * Push-on-idle now fires when mail arrives rather than only on a busy→idle edge, + * which puts restart squarely on the delivery path — a pane comes back carrying + * the title it had at snapshot time, and anything the runtime infers from that + * is a memory, not an observation. Typing on it would submit into an agent that + * may be mid-turn and stamp the row delivered, losing it. + * + * Scope, stated plainly: this covers the restart path, not the + * `lastAgentStatusObservedLive` gate itself. The seed only reaches leaves that + * already exist when pty:spawn returns the restore payload, and a cold relaunch + * publishes its graph after that — so the leaf here comes back with no agent + * status rather than a seeded idle, and this spec passes with the gate removed. + * The gate is pinned in src/main/runtime/orca-runtime.test.ts + * ('does not push on a cold-restore seeded idle status with no live + * observation'), which can stage that ordering directly. What earns this spec + * its two Electron launches is that neither half of the restart behavior above + * is reachable from a single-launch spec at all. + */ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId +} from './helpers/terminal' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' +import { + CODEX_IDLE_TITLE, + CODEX_WORKING_TITLE, + createMailPaneAgent +} from './helpers/orchestration-mail-pane-agent' +import { mailDisposition, readMailRow } from './helpers/orchestration-mail-store' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +const BANNER_PREFIX = '--- Orchestration Messages' +const NO_DELIVERY_SETTLE_MS = 5_000 +const DELIVERY_TIMEOUT_MS = 20_000 + +test.describe.configure({ mode: 'serial' }) + +async function waitForRegisteredWorktree(client: RuntimeClient, worktreeId: string): Promise { + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + return listed.result.worktrees.some((worktree) => worktree.id === worktreeId) + }, + { timeout: 60_000, message: 'runtime never registered the worktree' } + ) + .toBe(true) +} + +async function waitForObservedTitle( + client: RuntimeClient, + handle: string, + title: string +): Promise { + await expect + .poll( + async () => { + const listed = await client.call('terminal.list') + return listed.result.terminals.find((entry) => entry.handle === handle)?.title ?? null + }, + { timeout: 30_000, message: `runtime never observed the title ${title}` } + ) + .toBe(title) +} + +test('keeps mail pending across a restart and delivers it when the agent reports live', async (// oxlint-disable-next-line no-empty-pattern -- this spec owns both Electron launches and opts out of the shared app fixture. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + const firstClient = new RuntimeClient(session.userDataDir, 30_000, null, null) + await waitForRegisteredWorktree(firstClient, worktreeId) + + // The pane attachRepoAndOpenTerminal already opened is mounted, so its leaf + // exists; terminal.create would instead race a 10s renderer graph-sync wait + // that a headless CI renderer loses. + const ptyId = await waitForActivePanePtyId(first.page) + const { paneKey } = await waitForActivePaneHookDescriptor(first.page) + const originalHandle = ( + await firstClient.call<{ terminal: { handle: string } }>('terminal.resolvePane', { paneKey }) + ).result.terminal.handle + const originalPtyId = ptyId + + // Keystrokes typed before the shell reaches its prompt are dropped outright. + await waitForPtyShellEcho(first.page, ptyId, 60_000) + const agent = createMailPaneAgent() + await execInTerminal(first.page, ptyId, agent.launchCommand) + await expect + .poll(() => agent.hasStarted(), { timeout: 60_000, message: 'agent never started' }) + .toBe(true) + + agent.setTitle(CODEX_WORKING_TITLE) + await waitForObservedTitle(firstClient, originalHandle, CODEX_WORKING_TITLE) + agent.setTitle(CODEX_IDLE_TITLE) + await waitForObservedTitle(firstClient, originalHandle, CODEX_IDLE_TITLE) + const titlesBeforeRestart = agent.titleEmitCount() + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + const secondClient = new RuntimeClient(session.userDataDir, 30_000, null, null) + + // The PTY outlives the app, so the restored pane is found by process + // identity; its handle may or may not be the one the first launch minted. + let restoredHandle: string | null = null + await expect + .poll( + async () => { + const listed = await secondClient.call('terminal.list') + const restored = listed.result.terminals.find( + (entry) => entry.ptyId === originalPtyId && entry.writable + ) + restoredHandle = restored?.handle ?? null + return restored?.title ?? null + }, + { timeout: 120_000, message: 'agent pane never came back writable after restart' } + ) + .toBe(CODEX_IDLE_TITLE) + expect(restoredHandle).toBeTruthy() + + // The process has emitted nothing since the restart, so whatever the runtime + // believes about this pane's status came back with the graph, not from it. + expect(agent.titleEmitCount()).toBe(titlesBeforeRestart) + + const sent = await secondClient.call<{ message: { id: string } }>('orchestration.send', { + to: restoredHandle!, + from: 'e2e-sender', + subject: 'Seeded idle must wait', + body: 'e2e body', + type: 'status' + }) + const messageId = sent.result.message.id + + // Why a fixed wait: expect.poll would settle on the first 'pending' reading, + // before the push had any chance to run, and assert nothing. + expect(readMailRow(session.userDataDir, messageId)).toBeDefined() + await second.page.waitForTimeout(NO_DELIVERY_SETTLE_MS) + expect(mailDisposition(readMailRow(session.userDataDir, messageId))).toBe('pending') + expect(agent.readStdin()).not.toContain(BANNER_PREFIX) + + // Re-emitting the SAME idle title changes no status — only its liveness — so + // the row moving here is delivery resuming on the agent's own signal. + agent.setTitle(CODEX_IDLE_TITLE) + await expect + .poll(() => agent.titleEmitCount(), { timeout: 30_000 }) + .toBeGreaterThan(titlesBeforeRestart) + await expect + .poll(() => agent.readStdin(), { + timeout: DELIVERY_TIMEOUT_MS, + message: 'live idle frame never released the pending mail' + }) + .toContain(BANNER_PREFIX) + await expect + .poll(() => mailDisposition(readMailRow(session.userDataDir, messageId)), { + timeout: DELIVERY_TIMEOUT_MS + }) + .toBe('pushed') + } finally { + if (firstApp) { + await session.close(firstApp) + } + if (secondApp) { + await session.close(secondApp) + } + await session.dispose() + } +}) From f23e6f2f900f1d1b177439f2bd2188c7974a0b0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:16:17 +0000 Subject: [PATCH 61/88] release: v1.4.176-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 108a21d5b52..75a8ad9acef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.176-rc.0", + "version": "1.4.176-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 87d1d1a8dcfcbe1babe965a2b54cf820906a97dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:57:00 -0700 Subject: [PATCH 62/88] fix(terminal): preserve pinned scroll across live redraws (#12783) --- .../terminal-pane/pty-connection.ts | 13 + .../terminal-live-scrollback-restore.ts | 227 +++++++ .../xterm-user-scrolling-contract.test.ts | 586 +++++++++++++++++- 3 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-live-scrollback-restore.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8167a1c49cc..9b843723a2b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -165,6 +165,10 @@ import { import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' +import { + installTerminalLiveScrollbackRestore, + type TerminalLiveScrollbackRestore +} from '@/lib/pane-manager/terminal-live-scrollback-restore' import { enforceTerminalCurrentScrollIntent, getTerminalScrollIntentKind, @@ -4463,6 +4467,7 @@ export function connectPanePty( // override, which legitimately parks the PTY at phone dims). See // pty-size-reconcile.ts for the convergence loop. let ptySizeReconcileHandle: PtySizeReconcileHandle | null = null + let liveScrollbackRestore: TerminalLiveScrollbackRestore | null = null const reconcilePtySizeAfterSpawn = ( ptyId: string, spawnCols: number, @@ -6401,6 +6406,12 @@ export function connectPanePty( recordHiddenMode2031Reply() } + // Why installed here: the handler observes CSI 3 J inside xterm's parse, so a + // redraw split across PTY chunks is reported once, with the pane's rows for + // this write already applied. + liveScrollbackRestore?.dispose() + liveScrollbackRestore = installTerminalLiveScrollbackRestore(pane.terminal) + function writePtyOutputToXterm( data: string, foreground: boolean, @@ -9321,6 +9332,8 @@ export function connectPanePty( unregisterUndeliverableWriteHandler() unsubscribeRemoteDesktopActivationClaim() cancelHiddenOutputSnapshotScrollRestore() + liveScrollbackRestore?.dispose() + liveScrollbackRestore = null structuralReplayCoordinator.dispose() cancelFreshSpawnFollowReset() // Why: cancel the post-spawn reconcile's pending rAF so a torn-down pane can't keep fitting/resizing after disposal. diff --git a/src/renderer/src/lib/pane-manager/terminal-live-scrollback-restore.ts b/src/renderer/src/lib/pane-manager/terminal-live-scrollback-restore.ts new file mode 100644 index 00000000000..6b205fa55cd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-live-scrollback-restore.ts @@ -0,0 +1,227 @@ +import { guardParserHandler } from '@/components/terminal-pane/terminal-parser-handler-guard' +import { + isTerminalViewportAtBottom, + readTerminalScrollBufferSnapshot +} from './terminal-scroll-buffer-snapshot' +import { + captureTerminalStructuralScrollIntent, + isTerminalStructuralScrollIntentCurrent, + restoreTerminalStructuralScrollIntent, + type TerminalScrollIntentTarget, + type TerminalStructuralScrollIntentSnapshot +} from './terminal-scroll-intent' + +const ERASE_IN_DISPLAY_FINAL = 'J' +const ERASE_SCROLLBACK_PARAMETER = 3 + +// Why a quiet period rather than "the buffer stopped growing": a redraw streams +// over many parsed writes and any one of them can carry only cursor or colour +// bytes, so a single non-growing write is not the end of the rebuild. +const REDRAW_SETTLE_MS = 120 + +// Why a hard cap: output that never goes quiet would defer the pin forever. +// Restore once at the cap instead, so a long redraw still lands the reader. +const MAX_PENDING_RESTORE_MS = 1_000 + +type CancelScheduled = () => void +type ScheduleSettle = (run: () => void, delayMs: number) => CancelScheduled + +const scheduleSettleWithTimeout: ScheduleSettle = (run, delayMs) => { + const timer = setTimeout(run, delayMs) + return () => clearTimeout(timer) +} + +export type TerminalLiveScrollbackRestoreTarget = TerminalScrollIntentTarget & { + parser?: { + registerCsiHandler?: ( + id: { prefix?: string; final: string }, + handler: (params: (number | number[])[]) => boolean + ) => { dispose: () => void } + } + onWriteParsed?: (listener: () => void) => { dispose: () => void } +} + +export type TerminalLiveScrollbackRestore = { + dispose: () => void +} + +type PendingRestore = { + snapshot: TerminalStructuralScrollIntentSnapshot + bottomOffset: number + deadline: number + cancelSettle: CancelScheduled +} + +/** + * Preserves a pinned viewport across a live CSI 3 J that erases scrollback. + * + * Why a parser handler and not a scan of the outgoing bytes: xterm's parser + * already carries sequence state across writes, so a redraw split over several + * PTY chunks is reported once, at the exact point the erase is about to run. + * That is also the only place where the rows written earlier in the same chunk + * are already reflected in `baseY`, so the captured distance from the bottom is + * the reader's true position — and where the buffer type tells us whether the + * erase touches the scrollback we pinned at all. + * + * The pin fires exactly once, after the redraw goes quiet. Re-applying it per + * write would turn a pinned viewport into follow-output-at-an-offset and drag + * the reader through whatever the program printed next. + * + * Capture and restore both hang off xterm's own events, so installing this is + * the whole integration — there is no per-write wiring a caller can drop. + */ +export function installTerminalLiveScrollbackRestore( + terminal: TerminalLiveScrollbackRestoreTarget, + options: { now?: () => number; scheduleSettle?: ScheduleSettle } = {} +): TerminalLiveScrollbackRestore { + const now = options.now ?? (() => performance.now()) + const scheduleSettle = options.scheduleSettle ?? scheduleSettleWithTimeout + let pending: PendingRestore | null = null + + function disarm(): void { + pending?.cancelSettle() + pending = null + } + + function applyPendingRestore(active: PendingRestore): void { + // Why the identity check: a superseded pin's timer may still be in flight + // if a scheduler ever cancels late, and it must not spend its replacement. + if (pending !== active) { + return + } + const current = readTerminalScrollBufferSnapshot(terminal) + if (!current) { + disarm() + return + } + // These two restate guards that restoreTerminalStructuralScrollIntent also + // enforces, so no test can tell them apart from it — they are kept so this + // module's own preconditions do not depend on that one staying honest. + // The reader scrolled again, so their newer intent supersedes the pin. + if (!isTerminalStructuralScrollIntentCurrent(terminal, active.snapshot)) { + disarm() + return + } + // The app switched screens before the redraw finished; the pin no longer + // refers to the buffer in front of the reader. + if (current.bufferType !== active.snapshot.bufferType) { + disarm() + return + } + // Why keep waiting: a stalled frame mid-redraw would otherwise spend the pin + // against a half-rebuilt buffer and clamp the reader to the top for good. + if (current.baseY < active.bottomOffset && now() < active.deadline) { + armSettle(active) + return + } + // Single shot: re-applying per write would turn the pin into a follow. + disarm() + // Why no retry when a structural rebuild is in flight: the replay + // coordinator owns the viewport in that window and restores this same + // intent itself, so a retry here would only fight it. + restoreTerminalStructuralScrollIntent(terminal, active.snapshot, { + restoreBy: 'bottomOffset' + }) + } + + function armSettle(active: PendingRestore): void { + active.cancelSettle() + const delay = Math.max(0, Math.min(REDRAW_SETTLE_MS, active.deadline - now())) + active.cancelSettle = scheduleSettle(() => applyPendingRestore(active), delay) + } + + function observeEraseInDisplay(params: (number | number[])[]): boolean { + // Why params[0] is enough for the subparameter form: xterm reports CSI 3:1 J + // as [3, [1]], so the leading parameter still compares equal. + if (params[0] !== ERASE_SCROLLBACK_PARAMETER) { + return false + } + const current = readTerminalScrollBufferSnapshot(terminal) + // Why: on the alternate screen the erase never reaches the scrollback we + // pinned, so there is nothing to rebuild and nothing to restore. The gate + // below happens to cover this too (the alt buffer keeps no scrollback, so + // its viewport always reads as bottomed); state the real reason anyway. + if (!current || current.bufferType !== 'normal') { + return false + } + // Why read the live viewport and not just the stored intent: a durable pin + // kept across a remount still reads pinnedViewport for a reader who has + // since returned to the bottom, and arming there would stop follow-output. + if (isTerminalViewportAtBottom(current.viewportY, current.baseY)) { + return false + } + const live = pending + // Why reuse the armed pin: a second erase inside the window would otherwise + // recapture from the buffer the first erase already wiped, pinning the + // reader to an offset measured against a stub of a screen. Keeping the + // original deadline also stops a repeating clear from deferring it forever. + if (live && isTerminalStructuralScrollIntentCurrent(terminal, live.snapshot)) { + armSettle(live) + return false + } + const captured = captureTerminalStructuralScrollIntent(terminal) + // Why skip follow-output: xterm already keeps a bottomed viewport at the + // bottom, and re-latching it would only churn the intent revision. + if (!captured || captured.kind !== 'pinnedViewport') { + return false + } + disarm() + const active: PendingRestore = { + // Why live coordinates: capture can substitute durable pre-remount ones, + // which measure the offset against a buffer the reader is not looking at. + snapshot: { + kind: 'pinnedViewport', + bufferType: current.bufferType, + viewportY: current.viewportY, + baseY: current.baseY, + revision: captured.revision + }, + bottomOffset: current.baseY - current.viewportY, + deadline: now() + MAX_PENDING_RESTORE_MS, + cancelSettle: () => {} + } + pending = active + armSettle(active) + // Why false: this observes the erase, it does not implement it — xterm's + // own handler still has to clear the scrollback. + return false + } + + // Why guarded: xterm runs custom handlers inside WriteBuffer._innerWrite with + // no try/catch, and one throw there wedges the pane's write pipeline for good. + const observeEraseInDisplayGuarded = guardParserHandler( + 'csi-erase-scrollback', + observeEraseInDisplay + ) + + // Why both forms: xterm routes the private `CSI ? 3 J` to its own handler, and + // that one erases scrollback too — a plain-only registration misses it. + const registrations = [ + terminal.parser?.registerCsiHandler?.( + { final: ERASE_IN_DISPLAY_FINAL }, + observeEraseInDisplayGuarded + ), + terminal.parser?.registerCsiHandler?.( + { prefix: '?', final: ERASE_IN_DISPLAY_FINAL }, + observeEraseInDisplayGuarded + ) + ] + + // Each parsed write pushes the quiet period out, so the pin lands after the + // rebuild rather than partway through it. + const parsedSubscription = terminal.onWriteParsed?.(() => { + if (pending) { + armSettle(pending) + } + }) + + return { + dispose(): void { + disarm() + parsedSubscription?.dispose() + for (const registration of registrations) { + registration?.dispose() + } + } + } +} diff --git a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts index 2141e2bf187..9fbbdea8f73 100644 --- a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts +++ b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts @@ -11,10 +11,12 @@ * If an xterm upgrade breaks any assertion here, the live write path loses * its follow/pin semantics silently — fix the write path before bumping. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' import packageJson from '../../../../../package.json' import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' +import { installTerminalLiveScrollbackRestore } from './terminal-live-scrollback-restore' +import { markTerminalFollowOutput, markTerminalPinnedViewport } from './terminal-scroll-intent' type TerminalWithBufferService = Terminal & { _core?: { @@ -33,6 +35,20 @@ async function writeLines(term: Terminal, count: number, label: string): Promise } } +/** 100 lines of scrollback with the viewport pinned 30 rows up and that pin recorded. */ +async function pinnedScrollbackTerminal(): Promise { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 100, 'before') + term.scrollLines(-30) + markTerminalPinnedViewport(term) + return term +} + describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => { it('pins headless and renderer xterm to the same version', () => { expect(packageJson.dependencies['@xterm/headless']).toBe( @@ -180,3 +196,571 @@ describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) }) }) + +/** + * Drives the settle timer off a virtual clock so tests can prove the debounce + * itself. Every schedule is its own live timer, as with setTimeout, so a + * missing cancel shows up as an extra timer firing rather than being absorbed. + */ +function manualSettleScheduler(): { + now: () => number + scheduleSettle: (run: () => void, delayMs: number) => () => void + advance: (ms: number) => void + scheduleCount: () => number + cancelCount: () => number + liveTimerCount: () => number +} { + type FakeTimer = { run: () => void; dueAt: number; dead: boolean } + const timers: FakeTimer[] = [] + let nowMs = 0 + let scheduleCount = 0 + let cancelCount = 0 + return { + now: () => nowMs, + scheduleSettle: (run, delayMs) => { + const timer: FakeTimer = { run, dueAt: nowMs + delayMs, dead: false } + timers.push(timer) + scheduleCount += 1 + return () => { + if (!timer.dead) { + cancelCount += 1 + } + timer.dead = true + } + }, + advance: (ms) => { + nowMs += ms + // A regression that re-arms with no delay would otherwise spin here and + // read as a stuck CI job rather than a failing assertion. + for (let fired = 0; ; fired += 1) { + if (fired > 1_000) { + throw new Error('settle scheduler livelocked: zero-delay re-arm loop') + } + let due: FakeTimer | null = null + for (const timer of timers) { + if (!timer.dead && timer.dueAt <= nowMs && (!due || timer.dueAt < due.dueAt)) { + due = timer + } + } + if (!due) { + return + } + due.dead = true + due.run() + } + }, + scheduleCount: () => scheduleCount, + cancelCount: () => cancelCount, + liveTimerCount: () => timers.filter((timer) => !timer.dead).length + } +} + +const SETTLE_MS = 120 + +async function pinnedTerminalWithRestore(): Promise<{ + term: TerminalWithBufferService + advance: (ms: number) => void + settle: () => void + bottomOffset: number + restore: { dispose: () => void } + scheduleCount: () => number + cancelCount: () => number +}> { + const term = await pinnedScrollbackTerminal() + const scheduler = manualSettleScheduler() + const restore = installTerminalLiveScrollbackRestore(term, { + now: scheduler.now, + scheduleSettle: scheduler.scheduleSettle + }) + return { + term, + advance: scheduler.advance, + settle: () => scheduler.advance(SETTLE_MS), + bottomOffset: term.buffer.active.baseY - term.buffer.active.viewportY, + restore, + scheduleCount: scheduler.scheduleCount, + cancelCount: scheduler.cancelCount + } +} + +describe('live scrollback-erase pin (CSI 3 J)', () => { + it('restores a pinned viewport after a live full-screen scrollback clear', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[?2026h\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}\x1b[?2026l`) + expect(term.buffer.active.viewportY).toBe(0) + settle() + + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY - bottomOffset) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + }) + + it('triggers on a bare erase-scrollback with no screen clear alongside it', async () => { + const { term, settle, bottomOffset, scheduleCount } = await pinnedTerminalWithRestore() + + // No \x1b[2J prefix: the scrollback erase alone is the trigger. + await write(term, `\x1b[3J${'after\r\n'.repeat(150)}`) + expect(scheduleCount()).toBeGreaterThan(0) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('ignores erase-in-display forms that keep the scrollback', async () => { + const { term, settle, scheduleCount } = await pinnedTerminalWithRestore() + const pinnedY = term.buffer.active.viewportY + + // Every one of these is a routine TUI repaint; none erases scrollback. + for (const sequence of [ + '\x1b[J', + '\x1b[0J', + '\x1b[1J', + '\x1b[2J', + '\x1b[?0J', + '\x1b[?1J', + '\x1b[?2J' + ]) { + await write(term, sequence) + } + // Grow the buffer so a spurious bottom-offset restore would be visible. + await writeLines(term, 200, 'after') + expect(scheduleCount()).toBe(0) + settle() + + expect(term.buffer.active.viewportY).toBe(pinnedY) + }) + + it('detects a clear split across writes', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // xterm's parser carries the half-parsed sequence across the write boundary. + await write(term, '\x1b[2J\x1b[H\x1b[') + await write(term, `3J${'after\r\n'.repeat(50)}`) + settle() + + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY - bottomOffset) + }) + + it('holds the pin while the redraw keeps arriving and lands it once quiet', async () => { + const { term, advance, bottomOffset } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(60)}`) + // Each frame lands inside the quiet period, so the pin must not fire yet. + for (let frame = 0; frame < 5; frame += 1) { + advance(SETTLE_MS - 60) + expect(term.buffer.active.viewportY).toBe(0) + await write(term, 'after\r\n'.repeat(40)) + } + advance(SETTLE_MS) + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('lands the pin after a redraw that outgrows the erased scrollback over several writes', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // Each batch alone would settle the pin at the wrong distance from the bottom. + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(101)}`) + await write(term, 'after\r\n'.repeat(199)) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('survives a mid-redraw write that adds no rows', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}`) + // A cursor/attribute-only frame is normal mid-repaint and must not end the pin. + await write(term, '\x1b[K') + await write(term, 'after\r\n'.repeat(250)) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('spends the pin once even when the rebuild stays shorter than the old scrollback', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // A rebuild shorter than the pre-erase buffer skips the intent re-latch, so + // only the single-shot disarm stops this from becoming a follow-at-offset. + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(60)}`) + settle() + const restoredY = term.buffer.active.viewportY + expect(restoredY).toBe(term.buffer.active.baseY - bottomOffset) + + await writeLines(term, 200, 'later') + settle() + expect(term.buffer.active.viewportY).toBe(restoredY) + }) + + it('does not drag the reader through output that arrives after the pin lands', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`) + settle() + const restoredY = term.buffer.active.viewportY + expect(restoredY).toBe(term.buffer.active.baseY - bottomOffset) + + // The pin is spent: the reader holds position while the program keeps printing. + await writeLines(term, 200, 'later') + settle() + expect(term.buffer.active.viewportY).toBe(restoredY) + }) + + it('measures the pin from the rows already written earlier in the same chunk', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + const rowsBeforeErase = 20 + + // Those rows push the reader further from the bottom before the erase runs; + // capturing at the write boundary instead would undercount them. + await write( + term, + `${'pre\r\n'.repeat(rowsBeforeErase)}\x1b[2J\x1b[H\x1b[3J${'post\r\n'.repeat(120)}` + ) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe( + bottomOffset + rowsBeforeErase + ) + }) + + it('keeps the original pin when a second erase lands inside the settle window', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // The first redraw regrows past a screenful, so at the second erase the + // reader reads as detached at line 0. Re-measuring there would capture the + // whole rebuilt height as the offset and strand them at the top. + await write(term, `\x1b[2J\x1b[H\x1b[3J${'first\r\n'.repeat(160)}`) + expect(term.buffer.active.viewportY).toBe(0) + expect(term.buffer.active.baseY).toBeGreaterThan(0) + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'second\r\n'.repeat(160)}`) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it("re-captures from the reader's new position when they move between two erases", async () => { + const { term, advance, scheduleCount } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'first\r\n'.repeat(300)}`) + expect(scheduleCount()).toBeGreaterThan(0) + + // The reader moves during the rebuild, so the armed pin is superseded and + // the next erase has to measure them where they are now. + term.scrollLines(250) + markTerminalPinnedViewport(term) + const movedOffset = term.buffer.active.baseY - term.buffer.active.viewportY + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'second\r\n'.repeat(300)}`) + advance(2_000) + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(movedOffset) + }) + + it('keeps one deadline across a repeating clear so the pin still lands', async () => { + const { term, advance, bottomOffset } = await pinnedTerminalWithRestore() + + // A clear loop re-arms faster than the settle, so the pin only ever lands + // because the deadline stays anchored to the first erase. Ten 100ms cycles + // reach MAX_PENDING_RESTORE_MS exactly. + for (let cycle = 0; cycle < 10; cycle += 1) { + await write(term, `\x1b[2J\x1b[H\x1b[3J${'redraw\r\n'.repeat(140)}`) + expect(term.buffer.active.viewportY).toBe(0) + advance(100) + } + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('does not spend the pin on a redraw that stalls half-built', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // A delivery gap mid-redraw fires the settle while the rebuild is too short. + await write(term, `\x1b[2J\x1b[H\x1b[3J${'partial\r\n'.repeat(5)}`) + settle() + expect(term.buffer.active.viewportY).toBe(0) + + await write(term, 'rest\r\n'.repeat(140)) + settle() + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('gives up on a rebuild that never reaches the offset before the deadline', async () => { + const { term, advance, bottomOffset, scheduleCount } = await pinnedTerminalWithRestore() + + // Land inside 0 < baseY < bottomOffset, the band a partial rebuild sits in. + await write(term, `\x1b[2J\x1b[H\x1b[3J${'partial\r\n'.repeat(20)}`) + expect(term.buffer.active.baseY).toBeGreaterThan(0) + expect(term.buffer.active.baseY).toBeLessThan(bottomOffset) + + advance(2_000) + const schedulesAfterGiveUp = scheduleCount() + // The erase already clamped the reader here; giving up must not move them. + expect(term.buffer.active.viewportY).toBe(0) + + // The pin is spent, so a later rebuild tall enough to honor it must not fire. + await writeLines(term, 300, 'later') + advance(2_000) + expect(scheduleCount()).toBe(schedulesAfterGiveUp) + expect(term.buffer.active.viewportY).toBe(0) + }) + + it('does not arm for a reader who is back at the bottom under a stale durable pin', async () => { + const term = await pinnedScrollbackTerminal() + const scheduler = manualSettleScheduler() + installTerminalLiveScrollbackRestore(term, { + now: scheduler.now, + scheduleSettle: scheduler.scheduleSettle + }) + + // A remount/replay leaves the live buffer shorter than the durable pin, so + // capture prefers the stored pre-remount coordinates even though the reader + // is now following live output at the bottom. + term.clear() + term.scrollToBottom() + await writeLines(term, 40, 'replayed') + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + expect(term.buffer.active.baseY).toBeLessThan(91) + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(200)}`) + // No pin is armed at all, so nothing can later pull them off the bottom. + expect(scheduler.scheduleCount()).toBe(0) + scheduler.advance(SETTLE_MS) + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) + + it('measures the pin from live coordinates, not stale durable ones', async () => { + const term = await pinnedScrollbackTerminal() + const scheduler = manualSettleScheduler() + installTerminalLiveScrollbackRestore(term, { + now: scheduler.now, + scheduleSettle: scheduler.scheduleSettle + }) + + // A remount/replay leaves the durable intent describing a taller buffer than + // the one on screen, so its offset no longer matches what the reader sees. + term.clear() + term.scrollToBottom() + await writeLines(term, 40, 'replayed') + term.scrollLines(-5) + const liveOffset = term.buffer.active.baseY - term.buffer.active.viewportY + expect(liveOffset).toBe(5) + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(200)}`) + scheduler.advance(SETTLE_MS) + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(liveOffset) + }) + + it('restores after the private CSI ? 3 J erase', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + // xterm routes the private form to its own handler and it erases scrollback too. + await write(term, `\x1b[2J\x1b[H\x1b[?3J${'after\r\n'.repeat(50)}`) + settle() + + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY - bottomOffset) + }) + + it('restores after the subparameter erase form', async () => { + const { term, settle, bottomOffset } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3:1J${'after\r\n'.repeat(120)}`) + settle() + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + }) + + it('does not arm for an erase on the alternate screen', async () => { + const { term, settle, scheduleCount } = await pinnedTerminalWithRestore() + const pinnedY = term.buffer.active.viewportY + + await write(term, '\x1b[?1049h\x1b[H\x1b[2J\x1b[3Jframe') + expect(scheduleCount()).toBe(0) + + await write(term, '\x1b[?1049l') + await writeLines(term, 60, 'after') + settle() + + // xterm carries the pin itself; a restore firing here would drag the reader. + expect(term.buffer.active.viewportY).toBe(pinnedY) + }) + + it('drops the pin when the app switches to the alternate screen before it settles', async () => { + const { term, settle } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`) + await write(term, '\x1b[?1049h') + const altY = term.buffer.active.viewportY + settle() + + expect(term.buffer.active.viewportY).toBe(altY) + }) + + it('abandons the pin once the reader scrolls again', async () => { + const { term, settle } = await pinnedTerminalWithRestore() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`) + term.scrollLines(-2) + markTerminalPinnedViewport(term) + const afterUserScrollY = term.buffer.active.viewportY + + settle() + expect(term.buffer.active.viewportY).toBe(afterUserScrollY) + }) + + it('leaves a follow-output pane following', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 100, 'before') + markTerminalFollowOutput(term) + const scheduler = manualSettleScheduler() + installTerminalLiveScrollbackRestore(term, { + now: scheduler.now, + scheduleSettle: scheduler.scheduleSettle + }) + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}`) + expect(scheduler.scheduleCount()).toBe(0) + scheduler.advance(SETTLE_MS) + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) + + it('cancels an armed pin on dispose', async () => { + const term = await pinnedScrollbackTerminal() + const scheduler = manualSettleScheduler() + const restore = installTerminalLiveScrollbackRestore(term, { + now: scheduler.now, + scheduleSettle: scheduler.scheduleSettle + }) + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`) + expect(scheduler.scheduleCount()).toBeGreaterThan(0) + const cancelsBeforeDispose = scheduler.cancelCount() + const clampedY = term.buffer.active.viewportY + + restore.dispose() + // The live timer is released, not just neutered, so nothing survives the pane. + expect(scheduler.cancelCount()).toBeGreaterThan(cancelsBeforeDispose) + scheduler.advance(SETTLE_MS) + expect(term.buffer.active.viewportY).toBe(clampedY) + }) + + it('stops observing erases after dispose', async () => { + const { term, settle, restore } = await pinnedTerminalWithRestore() + restore.dispose() + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(50)}`) + settle() + // Without the pin the reader is stranded where the erase clamped them. + expect(term.buffer.active.viewportY).toBe(0) + }) + + it('lands the pin on the real timer when no scheduler is injected', async () => { + const term = await pinnedScrollbackTerminal() + const restore = installTerminalLiveScrollbackRestore(term) + const bottomOffset = term.buffer.active.baseY - term.buffer.active.viewportY + + await write(term, `\x1b[2J\x1b[H\x1b[3J${'after\r\n'.repeat(120)}`) + await new Promise((resolve) => setTimeout(resolve, 400)) + + expect(term.buffer.active.baseY - term.buffer.active.viewportY).toBe(bottomOffset) + restore.dispose() + }) +}) + +describe('live scrollback-erase pin wiring', () => { + type StubRegistration = { id: { prefix?: string; final: string }; disposed: boolean } + + function stubTerminal(options: { throwOnBuffer?: boolean } = {}): { + target: Parameters[0] + registrations: StubRegistration[] + handlers: ((params: (number | number[])[]) => boolean)[] + parsedDisposed: () => boolean + } { + const registrations: StubRegistration[] = [] + const handlers: ((params: (number | number[])[]) => boolean)[] = [] + let parsedDisposed = false + const target = { + get buffer() { + if (options.throwOnBuffer) { + throw new Error('buffer is gone') + } + return { active: { type: 'normal', viewportY: 10, baseY: 50 } } + }, + parser: { + registerCsiHandler: ( + id: { prefix?: string; final: string }, + handler: (params: (number | number[])[]) => boolean + ) => { + const registration: StubRegistration = { id, disposed: false } + registrations.push(registration) + handlers.push(handler) + return { + dispose: () => { + registration.disposed = true + } + } + } + }, + onWriteParsed: () => ({ + dispose: () => { + parsedDisposed = true + } + }) + } + return { + target: target as Parameters[0], + registrations, + handlers, + parsedDisposed: () => parsedDisposed + } + } + + it('registers both the plain and private erase idents', () => { + const stub = stubTerminal() + installTerminalLiveScrollbackRestore(stub.target) + + expect(stub.registrations.map((registration) => registration.id)).toEqual([ + { final: 'J' }, + { prefix: '?', final: 'J' } + ]) + }) + + it('degrades a throwing capture to "not handled" instead of wedging the parser', () => { + const stub = stubTerminal({ throwOnBuffer: true }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + installTerminalLiveScrollbackRestore(stub.target) + + // xterm has no try/catch around parser handlers, so a throw here would stop + // the pane's write pipeline for good. + for (const handler of stub.handlers) { + expect(() => handler([3])).not.toThrow() + expect(handler([3])).toBe(false) + } + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() + }) + + it('releases both registrations and the parsed subscription on dispose', () => { + const stub = stubTerminal() + const restore = installTerminalLiveScrollbackRestore(stub.target) + + restore.dispose() + expect(stub.registrations.every((registration) => registration.disposed)).toBe(true) + expect(stub.parsedDisposed()).toBe(true) + }) + + it('does nothing when the terminal exposes no parser or parsed event', () => { + const restore = installTerminalLiveScrollbackRestore({ + buffer: { active: { type: 'normal', viewportY: 10, baseY: 50 } } + } as Parameters[0]) + expect(() => restore.dispose()).not.toThrow() + }) +}) From 7df7aa5fe993caef8f9c25e713451bda5b852a4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:27:54 +0000 Subject: [PATCH 63/88] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 872ae567b24..426e6249764 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 15m + + downloads: 16m @@ -15,7 +15,7 @@ downloads downloads - 15m - 15m + 16m + 16m From 1aaa5cfbd91b028791aeb8f49d46156c24ffc385 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:40:14 -0700 Subject: [PATCH 64/88] Fix cmd+j search ranking to require coverage of meaningful query words (#12954) * Fix cmd+j search ranking to require coverage of meaningful query words Extract tokenization logic into a shared module to ensure consistent ranking across settings and project search bands. Enhance ranking with: - Coverage requirement: candidates must match most meaningful words, not just one (fixes "linear triage" matching all projects on "linear" alone) - Filler words: ignore navigation words like "open", "go", "the" when measuring coverage - Unicode support: split on Unicode word boundaries, not ASCII only * Fix cmd+j search to require query coverage and handle Unicode The search ranking now requires all query words to match candidate values before applying shortcut rules, preventing false positives where middle words could be ignored. Query normalization now iterates over Unicode characters instead of code units to properly lowercase supplementary-plane characters. --- .../cmd-j/palette-project-results.ts | 88 +++---------- .../cmd-j/palette-query-tokens.test.ts | 15 +++ .../components/cmd-j/palette-query-tokens.ts | 116 ++++++++++++++++++ .../components/cmd-j/palette-results.test.ts | 77 ++++++++++++ .../src/components/cmd-j/palette-results.ts | 96 +++------------ 5 files changed, 243 insertions(+), 149 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-query-tokens.test.ts create mode 100644 src/renderer/src/components/cmd-j/palette-query-tokens.ts diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index e4b26441bde..d0fcc15636c 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -1,4 +1,9 @@ import { isCmdJPaletteQueryTooLarge } from './palette-results' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' import { @@ -39,73 +44,6 @@ type RankedProjectResult = { const PROJECT_GROUP_ALIASES = ['group', 'repo group'] const PROJECT_ALIASES = ['project', 'repo'] -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function buildCmdJProjectSearchCandidates({ projectGroups, repos, @@ -134,7 +72,7 @@ function buildCmdJProjectSearchCandidates({ ), rowKey: getProjectGroupHeaderKey(group.id), order, - keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([group.name, ...PROJECT_GROUP_ALIASES]) }) }) @@ -156,7 +94,11 @@ function buildCmdJProjectSearchCandidates({ rowKey: target.key, repo: target.repo, order: projectGroups.length + repoIndex, - keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES]) + keywords: uniqueNormalizedCmdJPaletteKeywords([ + target.label, + repo.displayName, + ...PROJECT_ALIASES + ]) }) }) @@ -191,7 +133,7 @@ function projectRankingForCandidate( query: string, candidate: CmdJProjectSearchResult ): RankedProjectResult | null { - const title = normalizeQuery(candidate.title) + const title = normalizeCmdJPaletteQuery(candidate.title) if (query === title) { return { result: candidate, rule: 1, score: 0 } } @@ -199,13 +141,13 @@ function projectRankingForCandidate( return { result: candidate, rule: 2, score: 0 } } const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES - if (aliasKeywords.map(normalizeQuery).includes(query)) { + if (aliasKeywords.map(normalizeCmdJPaletteQuery).includes(query)) { return { result: candidate, rule: 3, score: 0 } } if (candidate.keywords.some((keyword) => keyword.startsWith(query))) { return { result: candidate, rule: 4, score: 0 } } - const score = tokenScore(query, [candidate.title, ...candidate.keywords]) + const score = cmdJPaletteTokenScore(query, [candidate.title, ...candidate.keywords]) return score > 0 ? { result: candidate, rule: 5, score } : null } @@ -242,7 +184,7 @@ export function searchCmdJProjectResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) // Why: project/group rows sit after worktree matches, so one-character // searches would add broad noisy navigation targets before intent is clear. if (normalizedQuery.length < 2) { diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts new file mode 100644 index 00000000000..3f829ace14a --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCmdJPaletteQuery, tokenizeCmdJPaletteQuery } from './palette-query-tokens' + +describe('Cmd+J palette query normalization', () => { + it('lowercases supplementary-plane characters as whole code points', () => { + // Why: code-unit iteration split '\u{10400}' into surrogate halves, which lowercase to + // themselves, so the query never matched its lowercase counterpart. + expect(normalizeCmdJPaletteQuery('\u{10400} HELLO')).toBe('\u{10428} hello') + expect(tokenizeCmdJPaletteQuery('\u{10400} HELLO')).toEqual(['\u{10428}', 'hello']) + }) + + it('collapses surrounding and repeated whitespace', () => { + expect(normalizeCmdJPaletteQuery(' New\n\tTerminal ')).toBe('new terminal') + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-query-tokens.ts b/src/renderer/src/components/cmd-j/palette-query-tokens.ts new file mode 100644 index 00000000000..3ba9068b0c1 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-query-tokens.ts @@ -0,0 +1,116 @@ +// Why: the middle band and the project band scored queries with byte-identical copies of +// this logic, so ranking fixes only ever landed in one of the two. + +function isCmdJPaletteWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +// Why: iterating code units would lowercase surrogate halves separately, leaving +// supplementary-plane characters uncased and unmatchable. +export function normalizeCmdJPaletteQuery(value: string): string { + let normalized = '' + let pendingWhitespace = false + for (const character of value) { + if (isCmdJPaletteWhitespace(character.codePointAt(0) ?? 0)) { + pendingWhitespace = normalized.length > 0 + continue + } + if (pendingWhitespace) { + normalized += ' ' + pendingWhitespace = false + } + normalized += character.toLowerCase() + } + return normalized +} + +export function uniqueNormalizedCmdJPaletteKeywords(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeCmdJPaletteQuery).filter(Boolean))] +} + +// Why: splitting on non-ASCII would drop CJK and accented words entirely, so a localized +// query would silently skip the coverage rule below instead of failing it. +export function tokenizeCmdJPaletteQuery(value: string): string[] { + return normalizeCmdJPaletteQuery(value) + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean) +} + +// Why: navigation filler carries no intent ("open ssh settings" means "ssh settings"), so an +// unmatched filler word must not count against a candidate's coverage. +const CMD_J_QUERY_FILLER_TOKENS = new Set([ + 'a', + 'an', + 'and', + 'change', + 'edit', + 'for', + 'from', + 'go', + 'goto', + 'in', + 'into', + 'jump', + 'me', + 'my', + 'of', + 'on', + 'open', + 'please', + 'set', + 'show', + 'the', + 'to', + 'view', + 'with' +]) + +export function cmdJPaletteTokenScore(query: string, values: readonly string[]): number { + const candidateTokens = values.flatMap(tokenizeCmdJPaletteQuery) + if (candidateTokens.length === 0) { + return 0 + } + + let score = 0 + let meaningful = 0 + let covered = 0 + for (const queryToken of tokenizeCmdJPaletteQuery(query)) { + let best = 0 + for (const candidateToken of candidateTokens) { + if (candidateToken === queryToken) { + best = Math.max(best, 3) + } else if (candidateToken.startsWith(queryToken)) { + best = Math.max(best, 2) + } else if (candidateToken.includes(queryToken)) { + best = Math.max(best, 1) + } + } + score += best + if (CMD_J_QUERY_FILLER_TOKENS.has(queryToken)) { + continue + } + meaningful += 1 + if (best > 0) { + covered += 1 + } + } + + // Why: "linear triage" matched the Linear pane as strongly as "linear" did, so a candidate + // now has to cover most of what was typed, not just one word of it. + if (meaningful > 0 && covered * 2 <= meaningful) { + return 0 + } + return score +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 73049bcc149..bf875ebe9a2 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -208,6 +208,66 @@ describe('Cmd+J palette middle-band ranking', () => { }) }) + it('drops candidates that cover a minority of the words typed', () => { + // Why: "linear triage" used to surface the Linear and Integrations panes on the + // "linear" token alone, with the unmatched word costing nothing. See screenshot report. + const integrationSections: SettingsNavSection[] = [ + { + id: 'linear', + title: 'Linear', + description: 'How Linear works in Orca.', + icon: Settings, + searchEntries: [], + group: 'capabilities' + }, + { + id: 'integrations', + title: 'Integrations', + description: 'Connect GitHub, GitLab, and Linear.', + icon: Settings, + searchEntries: [], + group: 'setup' + } + ] + const rank = (query: string): string[] => + rankCmdJMiddleResults({ + query, + settingsResults: buildCmdJSettingsResults(integrationSections), + actionResults: [] + }).map((result) => result.id) + + expect(rank('linear triage')).toEqual([]) + expect(rank('linear')).toEqual(['settings:linear', 'settings:integrations']) + // Why: a majority still counts, so one stray word cannot blank an otherwise good match. + expect(rank('linear integrations triage')).toEqual(['settings:integrations']) + }) + + it('drops verb-prefixed settings queries whose middle words match nothing', () => { + // Why: the verb-plus-settings-keyword rule reads only the head and tail of the query, so + // "new terminal browser settings" used to win on those two ends alone. + expect(top('new terminal sparkles glitter browser settings')).toBeUndefined() + expect(top('new terminal settings')).toBe('settings:terminal') + }) + + it('keeps out-of-order partial-word matches when every query word lands somewhere', () => { + expect(top('font term')).toBe('settings:terminal') + expect(top('font sparkles')).toBeUndefined() + }) + + it('ignores navigation filler words when measuring coverage', () => { + // Why: "open"/"go to" carry no intent, so they must not read as unmatched words + // and blank the band mid-query. + expect(top('open terminal settings')).toBe('settings:terminal') + expect(top('go to ssh settings')).toBe('settings:ssh') + expect(top('change the terminal font')).toBe('settings:terminal') + }) + + it('counts non-latin query words instead of discarding them', () => { + // Why: tokenizing on ASCII only let a localized query skip the coverage rule entirely. + expect(top('ssh 主机 设置')).toBeUndefined() + expect(top('ssh 设置')).toBeUndefined() + }) + it('does not match settings on one-character or description-only queries', () => { expect(top('t')).toBeUndefined() expect(top('cookie import')).toBeUndefined() @@ -316,6 +376,23 @@ function projectGroup(id: string, name: string, parentGroupId: string | null = n } describe('Cmd+J project and repo-group search', () => { + it('drops projects that only match the query through a generic alias word', () => { + // Why: every project carries the 'repo'/'project' aliases, so "repo triage" used to + // match all of them on that word alone — the same bug the middle band had. + const search = (query: string): string[] => + searchCmdJProjectResults({ + query, + projectGroups: [], + repos: [repo('repo-1', 'linear-sync'), repo('repo-2', 'billing')], + projects: [], + projectHostSetups: [] + }).map((result) => result.title) + + expect(search('repo triage')).toEqual([]) + expect(search('linear triage')).toEqual([]) + expect(search('linear repo')).toEqual(['linear-sync']) + }) + it('finds a Project Group by name', () => { const [result] = searchCmdJProjectResults({ query: 'infra', diff --git a/src/renderer/src/components/cmd-j/palette-results.ts b/src/renderer/src/components/cmd-j/palette-results.ts index 9246599843c..3b643c1fe8e 100644 --- a/src/renderer/src/components/cmd-j/palette-results.ts +++ b/src/renderer/src/components/cmd-j/palette-results.ts @@ -1,6 +1,11 @@ import type { SettingsNavIcon, SettingsNavSection } from '@/lib/settings-navigation-types' import type { CmdJQuickAction } from './quick-actions' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { + cmdJPaletteTokenScore, + normalizeCmdJPaletteQuery, + uniqueNormalizedCmdJPaletteKeywords +} from './palette-query-tokens' export type CmdJSettingsResult = { id: string @@ -53,40 +58,6 @@ export function isCmdJPaletteQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function normalizeQuery(value: string): string { - let normalized = '' - let pendingWhitespace = false - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (isCmdJPaletteWhitespace(code)) { - pendingWhitespace = normalized.length > 0 - continue - } - if (pendingWhitespace) { - normalized += ' ' - pendingWhitespace = false - } - normalized += value.charAt(index).toLowerCase() - } - return normalized -} - -function isCmdJPaletteWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - function keywordParts(section: SettingsNavSection): string[] { const baseId = section.id.startsWith('repo-') ? 'repo' : section.id const idWords = baseId.replace(/-/g, ' ') @@ -107,10 +78,6 @@ function targetEntryKeywordParts(entryTitle: string): string[] { return [entryTitle, `${entryTitle} settings`] } -function uniqueNormalized(values: readonly string[]): string[] { - return [...new Set(values.map(normalizeQuery).filter(Boolean))] -} - export function buildCmdJSettingsResults( sections: readonly SettingsNavSection[] ): CmdJSettingsResult[] { @@ -123,7 +90,7 @@ export function buildCmdJSettingsResults( icon: section.icon, sectionId: section.id, order, - configKeywords: uniqueNormalized(keywordParts(section)) + configKeywords: uniqueNormalizedCmdJPaletteKeywords(keywordParts(section)) } const targetedResults = section.searchEntries .filter((entry) => entry.targetSectionId) @@ -136,7 +103,7 @@ export function buildCmdJSettingsResults( sectionId: section.id, targetSectionId: entry.targetSectionId, order: order + (entryIndex + 1) / 100, - configKeywords: uniqueNormalized([ + configKeywords: uniqueNormalizedCmdJPaletteKeywords([ ...targetEntryKeywordParts(entry.title), ...(entry.cmdJKeywords ?? entry.keywords ?? []) ]) @@ -154,35 +121,6 @@ function startsOrIsStartedBy(query: string, keyword: string): boolean { return keyword.startsWith(query) || query.startsWith(keyword) } -function tokenize(value: string): string[] { - return normalizeQuery(value) - .split(/[^a-z0-9]+/) - .filter(Boolean) -} - -function tokenScore(query: string, values: readonly string[]): number { - const candidateTokens = values.flatMap(tokenize) - if (candidateTokens.length === 0) { - return 0 - } - - let score = 0 - for (const queryToken of tokenize(query)) { - let best = 0 - for (const candidateToken of candidateTokens) { - if (candidateToken === queryToken) { - best = Math.max(best, 3) - } else if (candidateToken.startsWith(queryToken)) { - best = Math.max(best, 2) - } else if (candidateToken.includes(queryToken)) { - best = Math.max(best, 1) - } - } - score += best - } - return score -} - function rankingForCandidate( query: string, candidate: CmdJMiddleResult, @@ -193,6 +131,17 @@ function rankingForCandidate( return null } + // Why: the shortcut rules below only inspect the head and tail of the query, so coverage has + // to gate all of them — otherwise "new terminal browser settings" still wins on rule 3. + const values = + candidate.kind === 'settings' + ? [candidate.title, ...candidate.configKeywords] + : [candidate.title, ...candidate.verbKeywords] + const score = cmdJPaletteTokenScore(query, values) + if (score === 0) { + return null + } + if (candidate.kind === 'action' && candidate.verbKeywords.some((keyword) => query === keyword)) { return { result: candidate, rule: 1, score: 0 } } @@ -227,12 +176,7 @@ function rankingForCandidate( return { result: candidate, rule: 5, score: 0 } } - const values = - candidate.kind === 'settings' - ? [candidate.title, ...candidate.configKeywords] - : [candidate.title, ...candidate.verbKeywords] - const score = tokenScore(query, values) - return score > 0 ? { result: candidate, rule: 6, score } : null + return { result: candidate, rule: 6, score } } function compareRanked(a: RankedResult, b: RankedResult): number { @@ -263,7 +207,7 @@ export function rankCmdJMiddleResults({ if (isCmdJPaletteQueryTooLarge(query)) { return [] } - const normalizedQuery = normalizeQuery(query) + const normalizedQuery = normalizeCmdJPaletteQuery(query) if (normalizedQuery.length < 2) { return [] } From e68831f32c9fdfeb9df9972110dcceb849385ad9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:43:28 +0900 Subject: [PATCH 65/88] fix(github-project): index fork upstream slugs for project row matching (#12822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(github-project): index fork upstream slugs for project row matching Project cards often reference the public upstream repo while the open clone's origin is a personal fork. Map the parent slug to the same Repo so selected-repo filters no longer hide every board row. Preserves origin-based getRepoSlug identity for non-project callers. Fixes #12647 * fix(github-project): match project rows against fork upstream slugs Resolve the referenced call to a nonexistent `resolveRepoUpstreamSlug` and match the persisted `repo.upstream` parent instead of issuing an extra `github.repoUpstream` RPC per repo on every index build — that lookup shells out to `gh repo view` for non-forks, so it would have gated the Projects tab on N network calls. `repo.upstream` is already resolved at repo-add time and backfilled at startup, so the fix costs no IPC. Origin matches take precedence over upstream ones so an open clone of the upstream repo itself is never made ambiguous by someone's fork of it. Also covers the two surfaces the origin-only match broke alongside the desktop table: mobile's project row matcher and the store-slice row-mutation routing. * fix(github-project): scope fork upstream matching by host and selection Round-1 review fixes on top of the upstream-slug index: - Apply origin-over-upstream precedence among *selected* repos instead of globally. An open-but-unselected clone of the upstream repo was shadowing the selected fork, so #12647 still reproduced for anyone holding both — and repo selection collapses to one repo per project key, which is exactly that case. - Scope a fork's upstream identity key to the fork's own origin host. Persistence strips upstream.host, so GHES forks never matched their own rows and a GHES fork's parent could bind a same-named github.com row. * fix(github-project): skip the fork alias when its own origin is unresolved Round-2 review fix. `githubHostFromIdentityKey` cannot tell "origin resolved to github.com" from "origin did not resolve" — both yield no host. A GHES fork whose slug resolution had failed (auth lapse, unreachable runtime) therefore landed in the github.com namespace, so an unrelated public Project row matched it and Start work opened the wrong clone on the wrong server. Require a resolved origin before indexing the upstream alias: it is the only host evidence there is, and a repo with an unresolved origin was already absent from the origin index, so nothing is lost that origin matching had. * fix(repos): persist the fork upstream host instead of dropping it `sanitizeRepoUpstream` kept only `{owner, repo}`, so a fork's parent lost the server it lives on every time the record round-tripped through disk. That forced the Project row matcher to re-infer the host from `origin`. The inference is right for an API-resolved fork parent — `getRepoUpstream` stamps `origin.host` there precisely because "a fork parent lives on the same server as the fork". It is wrong for the other branch: a local `upstream` remote carries its own host, so a github.com clone with a GHES `upstream` remote was indexed into the github.com namespace, where an unrelated same-owner/name public repo could claim it and Start work would open the wrong clone. Keeping the host removes the guess. Absent stays absent, so records written before this hydrate unchanged and the origin-derived fallback still covers them. Also fixes the avatar for rehydrated GHES forks, which resolved against github.com for the same reason. * docs(github-project): correct upstream host fallback comment Persistence now keeps non-empty upstream.host; originIdentityKey remains the host fallback for older records without one (CodeRabbit nit). * fix(github-project): own slug-index retry timer cleanup Move the failure-retry setTimeout into its own effect so cleanup always clears it. Scheduling from the async buildIndex then-handler failed the react-doctor effect-needs-cleanup gate in static analysis. * test(github-project): guard the slug-index retry timer, fix the mobile twin comment Two follow-ups on 52298d82 and 2f89c20d: - Cover the retry timer both ways: a failed resolution still re-resolves after the TTL and recovers the match, and the pending timer is gone after unmount. The second fails if the timer moves back into the async then-handler, so the property is guarded by more than the lint rule. - The mobile matcher's comment made the same stale "persistence strips upstream.host" claim that 2f89c20d fixed on the renderer side. * test(github-project): unmount slug-index hooks so React cannot flush after teardown CI shard `tests node 24 6/16` failed with 10 unhandled `ReferenceError: window is not defined` traced to this file. The tests mounted hooks without unmounting, so React scheduler work flushed after the DOM environment was disposed. All assertions passed; the shard failed on the unhandled errors alone. `cleanup()` after each test unmounts the trees. Does not reproduce locally in isolation — it needs CI's worker pooling and file ordering. --------- Co-authored-by: Jinwoo-H --- mobile/app/h/[hostId]/tasks.tsx | 2 + .../tasks/github-project-repo-match.test.ts | 130 ++++++++++++ mobile/src/tasks/github-project-repo-match.ts | 38 ++++ src/main/persistence.test.ts | 37 ++++ src/main/persistence.ts | 12 +- .../github-project/ProjectViewWrapper.tsx | 19 +- .../project-row-filtering.test.ts | 79 ++++++-- .../github-project/project-row-filtering.ts | 25 ++- src/renderer/src/lib/repo-slug-cache.test.ts | 56 ++++++ src/renderer/src/lib/repo-slug-cache.ts | 42 +++- src/renderer/src/lib/repo-slug-index.test.ts | 189 ++++++++++++++++++ src/renderer/src/lib/repo-slug-index.ts | 89 ++++++--- src/shared/github-repository-identity-key.ts | 9 + 13 files changed, 672 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/lib/repo-slug-index.test.ts diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index c41b165cbf9..0794c317e41 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -165,6 +165,8 @@ type RepoSummary = { kind?: 'git' | 'folder' connectionId?: string | null issueSourcePreference?: IssueSourcePreference + /** Fork parent resolved by the host; drives upstream Project row matching. */ + upstream?: { owner: string; repo: string; host?: string } | null } type IssueSourcePreference = 'upstream' | 'origin' | 'auto' diff --git a/mobile/src/tasks/github-project-repo-match.test.ts b/mobile/src/tasks/github-project-repo-match.test.ts index bce71393dab..58c44a57830 100644 --- a/mobile/src/tasks/github-project-repo-match.test.ts +++ b/mobile/src/tasks/github-project-repo-match.test.ts @@ -115,6 +115,136 @@ describe('GitHub project repo matching', () => { ).toBe(repos[1]) }) + it('matches an upstream project row against a fork clone', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', [fork], { + 'repo-1': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror' } + } + }) + ).toBe(fork) + }) + + it('prefers the clone that owns the slug over a fork of it', () => { + const upstreamClone = { id: 'repo-1', path: '/Users/me/r2r', displayName: 'r2r' } + const fork = { + id: 'repo-2', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', [upstreamClone, fork], { + 'repo-1': { + path: '/Users/me/r2r', + repository: { owner: 'SciPhi-AI', repo: 'R2R' } + }, + 'repo-2': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror' } + } + }) + ).toBe(upstreamClone) + }) + + it('does not pick a repo when two forks share the same upstream', () => { + const forks = [ + { + id: 'repo-1', + path: '/Users/me/a', + displayName: 'a', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + }, + { + id: 'repo-2', + path: '/Users/me/b', + displayName: 'b', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + ] + + expect( + findRepoForGitHubProjectRepository('SciPhi-AI/R2R', forks, { + 'repo-1': { path: '/Users/me/a', repository: { owner: 'me', repo: 'a' } }, + 'repo-2': { path: '/Users/me/b', repository: { owner: 'me', repo: 'b' } } + }) + ).toBeNull() + }) + + it('does not bind a github.com fork parent to a same-named Enterprise row', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/r2r-mirror', + displayName: 'r2r-mirror', + upstream: { owner: 'SciPhi-AI', repo: 'R2R' } + } + + expect( + findRepoForGitHubProjectRepository( + 'SciPhi-AI/R2R', + [fork], + { + 'repo-1': { + path: '/Users/me/r2r-mirror', + repository: { owner: 'me', repo: 'r2r-mirror', host: 'github.com' } + } + }, + 'github.acme-corp.com' + ) + ).toBeNull() + }) + + it('drops the fork alias while its own origin is unresolved', () => { + const fork = { + id: 'repo-1', + path: '/Users/me/widgets-mirror', + displayName: 'widgets-mirror', + upstream: { owner: 'acme', repo: 'widgets' } + } + + for (const slugs of [ + {}, + { 'repo-1': { path: '/Users/me/widgets-mirror', repository: null } }, + { 'repo-1': { path: '/moved', repository: { owner: 'me', repo: 'widgets' } } } + ]) { + expect(findRepoForGitHubProjectRepository('acme/widgets', [fork], slugs)).toBeNull() + } + }) + + it('scopes a host-less fork parent to the host the fork itself was cloned from', () => { + const enterpriseFork = { + id: 'repo-1', + path: '/Users/me/widgets-mirror', + displayName: 'widgets-mirror', + upstream: { owner: 'acme', repo: 'widgets' } + } + const slugs = { + 'repo-1': { + path: '/Users/me/widgets-mirror', + repository: { owner: 'me', repo: 'widgets', host: 'github.acme-corp.com' } + } + } + + expect( + findRepoForGitHubProjectRepository( + 'acme/widgets', + [enterpriseFork], + slugs, + 'github.acme-corp.com' + ) + ).toBe(enterpriseFork) + expect(findRepoForGitHubProjectRepository('acme/widgets', [enterpriseFork], slugs)).toBeNull() + }) + it('does not use hostless path heuristics for Enterprise Project rows', () => { expect( findRepoForGitHubProjectRepository( diff --git a/mobile/src/tasks/github-project-repo-match.ts b/mobile/src/tasks/github-project-repo-match.ts index c4d1e1d2ea6..1e9cd61996a 100644 --- a/mobile/src/tasks/github-project-repo-match.ts +++ b/mobile/src/tasks/github-project-repo-match.ts @@ -7,6 +7,9 @@ export type GitHubProjectRepoMatch = { id: string path: string displayName: string + /** Fork parent resolved by the host and carried on `repo.list`. Absent = not + * a fork or not yet resolved. */ + upstream?: { owner: string; repo: string; host?: string } | null } export type GitHubRepoSlugCacheEntry = { @@ -45,6 +48,27 @@ function cachedSlugStateForRepo( return { status: 'resolved', repository: cached.repository } } +/** Identity key of the repo's fork parent, or null when it is not a fork or its + * origin has not resolved. Why: when `upstream.host` is absent (older persisted + * forks), the fork's origin host is the fallback so GHES parents do not collapse + * into github.com. Unresolved origins refuse the alias. */ +function upstreamIdentityKeyForRepo( + repo: GitHubProjectRepoMatch, + originState: CachedSlugState | undefined +): string | null { + const upstream = repo.upstream + if (!upstream?.owner || !upstream.repo) { + return null + } + if (originState?.status !== 'resolved' || !originState.repository) { + return null + } + return githubRepoIdentityKey({ + ...upstream, + host: upstream.host ?? originState.repository.host + }) +} + export function findRepoForGitHubProjectRepository( repository: string | null | undefined, repos: GitHubProjectRepoMatch[], @@ -79,6 +103,20 @@ export function findRepoForGitHubProjectRepository( return null } + // Why: a Project card references the upstream repo, but a contributor's clone + // has their personal fork as `origin`, so origin-only matching hid every row + // (#12647). Checked after origin so an open clone of the upstream repo itself + // always wins over someone's fork of it. + const upstreamMatches = repos.filter( + (repo) => upstreamIdentityKeyForRepo(repo, slugStates.get(repo.id)) === requestedIdentityKey + ) + if (upstreamMatches.length === 1) { + return upstreamMatches[0]! + } + if (upstreamMatches.length > 1) { + return null + } + if (!isDefaultGitHubHost(projectHost)) { // Why: display names and local paths contain no host evidence, so using // them for GHES rows could bind an Enterprise item to a github.com repo. diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 059402ae9ed..b59ca6f5986 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4617,6 +4617,43 @@ describe('Store', () => { expect(store.getRepos()[0]!.upstream).toBeUndefined() }) + it('keeps the upstream host across reloads so it is never re-inferred from origin', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + upstream: { owner: ' acme ', repo: ' widgets ', host: ' GHE.example:8443 ' } + }) + expect(updated!.upstream).toEqual({ + owner: 'acme', + repo: 'widgets', + host: 'GHE.example:8443' + }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.upstream).toEqual({ + owner: 'acme', + repo: 'widgets', + host: 'GHE.example:8443' + }) + }) + + it('leaves a hostless persisted upstream hostless rather than inventing one', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } })) + + expect(store.getRepo('r1')!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) + expect(store.getRepo('r1')!.upstream).not.toHaveProperty('host') + }) + + it('drops a blank upstream host instead of persisting an empty string', async () => { + const store = await createStore() + store.addRepo(makeRepo({ upstream: { owner: 'acme', repo: 'widgets', host: ' ' } })) + + expect(store.getRepo('r1')!.upstream).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + it('updateRepo returns null for nonexistent id', async () => { const store = await createStore() expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 5d581a7f6e6..04188206ad7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1411,10 +1411,18 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined { if (!value || typeof value !== 'object') { return undefined } - const candidate = value as { owner?: unknown; repo?: unknown } + const candidate = value as { owner?: unknown; repo?: unknown; host?: unknown } const owner = typeof candidate.owner === 'string' ? candidate.owner.trim() : '' const repo = typeof candidate.repo === 'string' ? candidate.repo.trim() : '' - return owner && repo ? { owner, repo } : undefined + if (!owner || !repo) { + return undefined + } + // Why: an `upstream` remote may live on a different server than `origin`, so + // dropping the host forced consumers to re-infer it from origin and could bind + // a GHES parent to a same-named github.com repo. Absent host stays absent so + // records written before this survive unchanged. + const host = typeof candidate.host === 'string' ? candidate.host.trim() : '' + return host ? { owner, repo, host } : { owner, repo } } function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | null | undefined { diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 39f43539ee9..59d02e24f31 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -141,7 +141,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const patchProjectRowIssueType = useAppStore((s) => s.patchProjectRowIssueType) const addRepoFromStore = useAppStore((s) => s.addRepo) const repos = useAppStore((s) => s.repos) - const { lookupSlug, ready: slugIndexReady } = useRepoSlugIndex() + const { lookupSlug, lookupSlugMatches, ready: slugIndexReady } = useRepoSlugIndex() const mountedRef = useMountedRef() const activeProject = settings?.githubProjects?.activeProject ?? null @@ -375,9 +375,14 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const filteredTable = useMemo( () => table && slugIndexReady - ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + ? filterProjectTableRowsBySelectedRepos( + table, + lookupSlugMatches, + slugIndexReady, + selectedRepoIds + ) : null, - [table, slugIndexReady, lookupSlug, selectedRepoIds] + [table, slugIndexReady, lookupSlugMatches, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: ref-cache prevents a blank table while the slug index rebuilds, without forcing a second render. @@ -528,7 +533,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS } const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds @@ -584,7 +589,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS currentCacheKey, table, buildOrigin, - lookupSlug, + lookupSlugMatches, slugIndexReady, selectedRepoIds, openProjectRowUrlWithToast @@ -602,7 +607,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS } const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds @@ -671,7 +676,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS currentCacheKey, table, buildOrigin, - lookupSlug, + lookupSlugMatches, slugIndexReady, selectedRepoIds, openProjectRowUrlWithToast diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index 708cef2fac5..8871c047f7a 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -17,6 +17,11 @@ function repo(id: string): Repo { } } +/** The common case: every match owns the slug through its own origin remote. */ +function originOnly(matches: Repo[]): { origin: Repo[]; upstream: Repo[] } { + return { origin: matches, upstream: [] } +} + function row(id: string, repository: string | null): GitHubProjectRow { return { id, @@ -102,7 +107,8 @@ describe('filterProjectTableRowsBySelectedRepos', () => { const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + (slug) => + originOnly(slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), true, new Set(['repo-1']) ) @@ -115,7 +121,7 @@ describe('filterProjectTableRowsBySelectedRepos', () => { const rows = [row('hidden', 'acme/orca')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - () => [repo('repo-2')], + () => originOnly([repo('repo-2')]), true, new Set(['repo-1']) ) @@ -124,11 +130,24 @@ describe('filterProjectTableRowsBySelectedRepos', () => { expect(filtered.totalCount).toBe(0) }) + it('keeps a row whose only selected match is a fork of the row s repo', () => { + const rows = [row('fork-only', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => ({ origin: [], upstream: [repo('fork')] }), + true, + new Set(['fork']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['fork-only']) + expect(filtered.totalCount).toBe(1) + }) + it('keeps a row with multiple selected matches for action ambiguity handling', () => { const rows = [row('ambiguous', 'acme/orca')] const filtered = filterProjectTableRowsBySelectedRepos( table(rows), - () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + () => originOnly([repo('repo-1'), repo('repo-2'), repo('repo-3')]), true, new Set(['repo-1', 'repo-2']) ) @@ -141,7 +160,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports loading without reading stale slug matches', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('loading', 'acme/orca'), - lookupSlug: () => { + lookupSlugMatches: () => { throw new Error('should not read stale matches') }, slugIndexReady: false, @@ -154,7 +173,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports invalid slug for rows without a repository', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('missing-slug', null), - lookupSlug: () => [repo('repo-1')], + lookupSlugMatches: () => originOnly([repo('repo-1')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -165,7 +184,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports no global match when Orca has no repo for the slug', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('missing', 'acme/orca'), - lookupSlug: () => [], + lookupSlugMatches: () => originOnly([]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -176,7 +195,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('reports global-only matches when the repo is not selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('unselected', 'acme/orca'), - lookupSlug: () => [repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) @@ -187,7 +206,7 @@ describe('resolveSelectedProjectRowRepo', () => { it('returns the selected match when exactly one matching repo is selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('selected', 'acme/orca'), - lookupSlug: () => [repo('repo-1'), repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-2']) }) @@ -196,28 +215,64 @@ describe('resolveSelectedProjectRowRepo', () => { }) it('passes the project host into repository matching', () => { - const lookupSlug = vi.fn(() => [repo('repo-1')]) + const lookupSlugMatches = vi.fn(() => originOnly([repo('repo-1')])) expect( resolveSelectedProjectRowRepo({ row: row('enterprise', 'acme/orca'), - lookupSlug, + lookupSlugMatches, host: 'ghe.example:8443', slugIndexReady: true, selectedRepoIds: new Set(['repo-1']) }) ).toMatchObject({ status: 'selected_match' }) - expect(lookupSlug).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443') + expect(lookupSlugMatches).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443') }) it('reports ambiguity when multiple matching repos are selected', () => { const resolution = resolveSelectedProjectRowRepo({ row: row('ambiguous', 'acme/orca'), - lookupSlug: () => [repo('repo-1'), repo('repo-2')], + lookupSlugMatches: () => originOnly([repo('repo-1'), repo('repo-2')]), slugIndexReady: true, selectedRepoIds: new Set(['repo-1', 'repo-2']) }) expect(resolution.status).toBe('ambiguous_selected_match') }) + + it('falls through to a selected fork when the upstream clone is unselected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('fork-selected', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['fork']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'fork' } }) + }) + + it('prefers the selected upstream clone over a selected fork of it', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('both-selected', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['upstream', 'fork']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'upstream' } }) + }) + + it('still reports no selection when neither the upstream clone nor the fork is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('neither', 'acme/orca'), + lookupSlugMatches: () => ({ origin: [repo('upstream')], upstream: [repo('fork')] }), + slugIndexReady: true, + selectedRepoIds: new Set(['other']) + }) + + expect(resolution).toMatchObject({ + status: 'unselected_match', + globalMatches: [{ id: 'upstream' }, { id: 'fork' }] + }) + }) }) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 1aa2f4c3ed8..52dd0138274 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -6,6 +6,13 @@ export type ProjectRowSlugLookup = ( host?: string ) => readonly Repo[] +/** Origin matches own the slug through their own remote; upstream matches are + * forks whose parent the row names. */ +export type ProjectRowSlugMatchLookup = ( + slug: string | null | undefined, + host?: string +) => { origin: readonly Repo[]; upstream: readonly Repo[] } + export type SelectedProjectRowResolution = | { status: 'loading' } | { status: 'invalid_slug' } @@ -20,7 +27,7 @@ export type SelectedProjectRowResolution = export function resolveSelectedProjectRowRepo(input: { row: GitHubProjectRow - lookupSlug: ProjectRowSlugLookup + lookupSlugMatches: ProjectRowSlugMatchLookup host?: string slugIndexReady: boolean selectedRepoIds: ReadonlySet @@ -38,12 +45,20 @@ export function resolveSelectedProjectRowRepo(input: { return { status: 'invalid_slug' } } - const globalMatches = input.lookupSlug(repository, input.host) + const { origin, upstream } = input.lookupSlugMatches(repository, input.host) + const globalMatches = [...origin, ...upstream] if (globalMatches.length === 0) { return { status: 'no_global_match' } } - const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + // Why: prefer origin only among repos the user actually selected. Applying + // that preference globally let an open-but-unselected clone of the upstream + // repo hide the selected fork, reproducing #12647 for anyone holding both. + const selectedOrigin = origin.filter((match) => input.selectedRepoIds.has(match.id)) + const selectedMatches = + selectedOrigin.length > 0 + ? selectedOrigin + : upstream.filter((match) => input.selectedRepoIds.has(match.id)) if (selectedMatches.length === 0) { return { status: 'unselected_match', globalMatches } } @@ -76,14 +91,14 @@ export function filterProjectTableRowsByOpenRepos( export function filterProjectTableRowsBySelectedRepos( table: GitHubProjectTable, - lookupSlug: ProjectRowSlugLookup, + lookupSlugMatches: ProjectRowSlugMatchLookup, slugIndexReady: boolean, selectedRepoIds: ReadonlySet ): GitHubProjectTable { const rows = table.rows.filter((row) => { const resolution = resolveSelectedProjectRowRepo({ row, - lookupSlug, + lookupSlugMatches, host: table.project.host, slugIndexReady, selectedRepoIds diff --git a/src/renderer/src/lib/repo-slug-cache.test.ts b/src/renderer/src/lib/repo-slug-cache.test.ts index dd0f9e4bd28..d9de7ae3e7b 100644 --- a/src/renderer/src/lib/repo-slug-cache.test.ts +++ b/src/renderer/src/lib/repo-slug-cache.test.ts @@ -46,6 +46,62 @@ describe('repo slug cache host identity', () => { ).toEqual([enterprise]) }) + it('routes an upstream project row to the fork clone that tracks it', () => { + const fork = { ...repo('fork'), upstream: { owner: 'SciPhi-AI', repo: 'R2R' } } + slugByRepoId.set( + slugCacheKey(fork.id, settingsForRepoOwner(fork, null)), + githubRepoIdentityKey({ owner: 'me', repo: 'r2r-mirror' }) + ) + + expect(lookupReposBySlugFromCache([fork], null, 'SciPhi-AI/R2R')).toEqual([fork]) + }) + + it('prefers the clone that owns the slug over a fork of it', () => { + const origin = repo('origin') + const fork = { ...repo('fork'), upstream: { owner: 'SciPhi-AI', repo: 'R2R' } } + slugByRepoId.set( + slugCacheKey(origin.id, settingsForRepoOwner(origin, null)), + githubRepoIdentityKey({ owner: 'SciPhi-AI', repo: 'R2R' }) + ) + slugByRepoId.set( + slugCacheKey(fork.id, settingsForRepoOwner(fork, null)), + githubRepoIdentityKey({ owner: 'me', repo: 'r2r-mirror' }) + ) + + expect(lookupReposBySlugFromCache([origin, fork], null, 'SciPhi-AI/R2R')).toEqual([origin]) + }) + + it('does not route a GHES row to a same-named github.com fork parent', () => { + const fork = { ...repo('fork'), upstream: { owner: 'acme', repo: 'widgets' } } + slugByRepoId.set( + slugCacheKey(fork.id, settingsForRepoOwner(fork, null)), + githubRepoIdentityKey({ owner: 'me', repo: 'widgets' }) + ) + + expect(lookupReposBySlugFromCache([fork], null, 'acme/widgets', 'ghe.example:8443')).toEqual([]) + }) + + it('scopes a host-less fork parent to the host the fork itself was cloned from', () => { + const enterpriseFork = { ...repo('fork'), upstream: { owner: 'acme', repo: 'widgets' } } + slugByRepoId.set( + slugCacheKey(enterpriseFork.id, settingsForRepoOwner(enterpriseFork, null)), + githubRepoIdentityKey({ owner: 'me', repo: 'widgets', host: 'ghe.example:8443' }) + ) + + expect( + lookupReposBySlugFromCache([enterpriseFork], null, 'acme/widgets', 'ghe.example:8443') + ).toEqual([enterpriseFork]) + expect(lookupReposBySlugFromCache([enterpriseFork], null, 'acme/widgets')).toEqual([]) + }) + + it('drops the fork alias while its own origin is unresolved', () => { + const fork = { ...repo('fork'), upstream: { owner: 'acme', repo: 'widgets' } } + + expect(lookupReposBySlugFromCache([fork], null, 'acme/widgets')).toEqual([]) + slugByRepoId.set(slugCacheKey(fork.id, settingsForRepoOwner(fork, null)), null) + expect(lookupReposBySlugFromCache([fork], null, 'acme/widgets')).toEqual([]) + }) + it('expires negative slug resolutions so an external GHES login can recover', () => { const key = slugCacheKey('enterprise', null) rememberRepoSlug(key, null, 1_000) diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts index 38ea5d32995..cb6552aa572 100644 --- a/src/renderer/src/lib/repo-slug-cache.ts +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -4,11 +4,40 @@ import type { GlobalSettings, Repo } from '../../../shared/types' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' -import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' +import { + githubHostFromIdentityKey, + githubRepoIdentityKey +} from '../../../shared/github-repository-identity-key' /** Lowercased `owner/repo` → Repo[]. */ export type SlugIndex = Map +/** The two ways a slug can match a repo, kept apart so callers that also filter + * by repo selection can fall through to `upstream` instead of letting an + * unselected clone of the upstream repo shadow the selected fork. */ +export type RepoSlugMatches = { origin: Repo[]; upstream: Repo[] } + +/** Identity key of a fork's upstream parent — the second identity a Project row + * may legitimately match, since a contributor's clone has the personal fork as + * `origin`. `null` when the repo is not a fork or the key cannot be trusted. + * + * Why `originIdentityKey` is required: when `upstream.host` is absent (older + * persisted forks), the fork's origin host is the fallback so GHES parents do + * not collapse into github.com. Unresolved origins refuse the alias. */ +export function repoUpstreamIdentityKey( + repo: Repo, + originIdentityKey: string | null | undefined +): string | null { + const upstream = repo.upstream + if (!upstream?.owner || !upstream.repo || !originIdentityKey) { + return null + } + return githubRepoIdentityKey({ + ...upstream, + host: upstream.host ?? githubHostFromIdentityKey(originIdentityKey) + }) +} + /** Module-scope cache keyed by runtime scope + repo.id. A Repo that has already * failed resolution is recorded as `null` briefly so it is not retried on every * cell mount, while still recovering after an external GHES auth login. */ @@ -87,7 +116,8 @@ export function settingsForRepoOwner( /** Synchronous slug → Repo lookup against the already-resolved module cache. * Used by store slices (which can't run the async hook-based index) to route * project-row mutations to the matched repo's owner host; callers fall back to - * focused settings when nothing matches. */ + * focused settings when nothing matches. Origin matches win over upstream ones + * so a clone of the upstream repo itself is never shadowed by someone's fork. */ export function lookupReposBySlugFromCache( repos: readonly Repo[], settings: Pick | null | undefined, @@ -100,11 +130,15 @@ export function lookupReposBySlugFromCache( } const target = githubRepoIdentityKey({ owner, repo, host }) const matched: Repo[] = [] + const upstreamMatched: Repo[] = [] for (const repo of repos) { const cacheKey = slugCacheKey(repo.id, settingsForRepoOwner(repo, settings)) - if (slugByRepoId.get(cacheKey) === target) { + const originKey = slugByRepoId.get(cacheKey) + if (originKey === target) { matched.push(repo) + } else if (repoUpstreamIdentityKey(repo, originKey) === target) { + upstreamMatched.push(repo) } } - return matched + return matched.length > 0 ? matched : upstreamMatched } diff --git a/src/renderer/src/lib/repo-slug-index.test.ts b/src/renderer/src/lib/repo-slug-index.test.ts new file mode 100644 index 00000000000..0b710cf5438 --- /dev/null +++ b/src/renderer/src/lib/repo-slug-index.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/types' +import { useAppStore } from '@/store' +import { useRepoSlugIndex } from './repo-slug-index' +import { REPO_SLUG_FAILURE_TTL_MS, clearRepoSlugCacheValues } from './repo-slug-cache' + +const repoSlug = vi.fn() + +function makeRepo(overrides: Partial & { id: string }): Repo { + return { + path: `/repos/${overrides.id}`, + displayName: overrides.id, + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local', + ...overrides + } +} + +const initialState = useAppStore.getInitialState() + +function setRepos(repos: Repo[]): void { + useAppStore.setState({ ...initialState, repos, settings: initialState.settings }, true) +} + +beforeEach(() => { + clearRepoSlugCacheValues() + repoSlug.mockReset() + // Why: origin resolution goes through the preload bridge; the index under + // test only reads `repo.upstream` from the store for the fork alias. + Object.assign(window, { api: { gh: { repoSlug } } }) +}) + +afterEach(() => { + // Why: React schedules work outside the test tick, so a hook left mounted + // flushes after the DOM environment is torn down and throws "window is not + // defined" as an unhandled error. + cleanup() + setRepos([]) +}) + +async function lookup(slug: string): Promise { + const { result } = renderHook(() => useRepoSlugIndex()) + await waitFor(() => expect(result.current.ready).toBe(true)) + return [...result.current.lookupSlug(slug)] +} + +describe('useRepoSlugIndex fork upstream matching', () => { + it('matches a project row against a fork clone via its upstream parent', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([fork]) + repoSlug.mockResolvedValue({ owner: 'me', repo: 'r2r-mirror' }) + + expect(await lookup('SciPhi-AI/R2R')).toEqual([fork]) + }) + + it('still matches the fork by its own origin slug', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([fork]) + repoSlug.mockResolvedValue({ owner: 'me', repo: 'r2r-mirror' }) + + expect(await lookup('me/r2r-mirror')).toEqual([fork]) + }) + + it('prefers the clone that owns the slug over a fork of it', async () => { + const upstreamClone = makeRepo({ id: 'upstream', upstream: null }) + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([upstreamClone, fork]) + repoSlug.mockImplementation(async (args: { repoId?: string }) => + args.repoId === 'upstream' + ? { owner: 'SciPhi-AI', repo: 'R2R' } + : { owner: 'me', repo: 'r2r-mirror' } + ) + + expect(await lookup('SciPhi-AI/R2R')).toEqual([upstreamClone]) + }) + + it('does not bind a github.com fork parent to a same-named Enterprise row', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([fork]) + repoSlug.mockResolvedValue({ owner: 'me', repo: 'r2r-mirror' }) + + const { result } = renderHook(() => useRepoSlugIndex()) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.lookupSlug('SciPhi-AI/R2R', 'ghe.example')).toEqual([]) + }) + + it('resolves the slug index without extra upstream IPC', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([fork]) + repoSlug.mockResolvedValue({ owner: 'me', repo: 'r2r-mirror' }) + + expect(await lookup('SciPhi-AI/R2R')).toEqual([fork]) + expect(repoSlug).toHaveBeenCalledTimes(1) + }) + + it('scopes a host-less fork parent to the host the fork itself was cloned from', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'acme', repo: 'widgets' } }) + setRepos([fork]) + repoSlug.mockResolvedValue({ owner: 'me', repo: 'widgets', host: 'ghe.example' }) + + const { result } = renderHook(() => useRepoSlugIndex()) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.lookupSlug('acme/widgets', 'ghe.example')).toEqual([fork]) + expect(result.current.lookupSlug('acme/widgets')).toEqual([]) + }) + + it('drops the fork alias while its own origin is unresolved', async () => { + const fork = makeRepo({ id: 'fork', upstream: { owner: 'acme', repo: 'widgets' } }) + setRepos([fork]) + repoSlug.mockResolvedValue(null) + + expect(await lookup('acme/widgets')).toEqual([]) + }) + + it('lists a repo once when its upstream is its own origin', async () => { + const selfAliased = makeRepo({ id: 'self', upstream: { owner: 'acme', repo: 'widgets' } }) + setRepos([selfAliased]) + repoSlug.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + + const { result } = renderHook(() => useRepoSlugIndex()) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.lookupSlugMatches('acme/widgets')).toEqual({ + origin: [selfAliased], + upstream: [] + }) + }) + + it('reports origin and upstream matches separately for selection filtering', async () => { + const upstreamClone = makeRepo({ id: 'upstream', upstream: null }) + const fork = makeRepo({ id: 'fork', upstream: { owner: 'SciPhi-AI', repo: 'R2R' } }) + setRepos([upstreamClone, fork]) + repoSlug.mockImplementation(async (args: { repoId?: string }) => + args.repoId === 'upstream' + ? { owner: 'SciPhi-AI', repo: 'R2R' } + : { owner: 'me', repo: 'r2r-mirror' } + ) + + const { result } = renderHook(() => useRepoSlugIndex()) + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.lookupSlugMatches('SciPhi-AI/R2R')).toEqual({ + origin: [upstreamClone], + upstream: [fork] + }) + }) +}) + +describe('useRepoSlugIndex failure retry', () => { + afterEach(() => vi.useRealTimers()) + + it('re-resolves a failed slug after the failure TTL and stops once unmounted', async () => { + vi.useFakeTimers() + const repo = makeRepo({ id: 'flaky' }) + setRepos([repo]) + // Why: a null result is the negative-cached "not a GitHub repo" answer that + // arms the bounded retry. + repoSlug.mockResolvedValueOnce(null) + + const { result } = renderHook(() => useRepoSlugIndex()) + await act(async () => void (await vi.advanceTimersByTimeAsync(0))) + expect(result.current.ready).toBe(true) + expect(result.current.lookupSlug('acme/widgets')).toEqual([]) + expect(repoSlug).toHaveBeenCalledTimes(1) + + repoSlug.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + await act(async () => void (await vi.advanceTimersByTimeAsync(REPO_SLUG_FAILURE_TTL_MS + 10))) + expect(repoSlug.mock.calls.length).toBeGreaterThan(1) + expect(result.current.lookupSlug('acme/widgets')).toEqual([repo]) + }) + + it('clears the pending retry timer on unmount', async () => { + vi.useFakeTimers() + setRepos([makeRepo({ id: 'flaky' })]) + // Why: a permanently failing resolution keeps a retry armed, so an + // uncleaned timer is observable after teardown. The hook mounts once per + // project row, so such a timer would leak per row. + repoSlug.mockResolvedValue(null) + + const { unmount } = renderHook(() => useRepoSlugIndex()) + await act(async () => void (await vi.advanceTimersByTimeAsync(0))) + const armedCount = vi.getTimerCount() + + unmount() + expect(vi.getTimerCount()).toBeLessThan(armedCount) + }) +}) diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 1d5f0a7f172..d48dbb869f3 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -22,9 +22,11 @@ import { nextRepoSlugFailureRetryDelay, readRepoSlugCache, rememberRepoSlug, + repoUpstreamIdentityKey, settingsForRepoOwner, slugByRepoId, slugCacheKey, + type RepoSlugMatches, type SlugIndex } from './repo-slug-cache' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' @@ -126,7 +128,7 @@ async function resolveRepoSlug( async function buildIndex( repos: Repo[], settings: Pick | null | undefined -): Promise<{ index: SlugIndex; retryDelayMs: number | null }> { +): Promise<{ index: SlugIndex; upstreamIndex: SlugIndex; retryDelayMs: number | null }> { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its @@ -139,6 +141,7 @@ async function buildIndex( } } const next: SlugIndex = new Map() + const upstreamNext: SlugIndex = new Map() const results = await Promise.all( repos.map(async (r) => ({ repo: r, @@ -151,12 +154,26 @@ async function buildIndex( if (slug) { next.set(slug, [...(next.get(slug) ?? []), repo]) } + // Why: a Project card references the upstream repo, but a contributor's + // clone has their personal fork as `origin`, so the origin-only index + // dropped every row (#12647). `repo.upstream` is already resolved when the + // repo is added, so this costs no extra IPC. + const upstreamKey = repoUpstreamIdentityKey(repo, slug) + if (upstreamKey && upstreamKey !== slug) { + upstreamNext.set(upstreamKey, [...(upstreamNext.get(upstreamKey) ?? []), repo]) + } + } + return { + index: next, + upstreamIndex: upstreamNext, + retryDelayMs: nextRepoSlugFailureRetryDelay(liveKeys) } - return { index: next, retryDelayMs: nextRepoSlugFailureRetryDelay(liveKeys) } } export type RepoSlugIndexState = { + /** Best available matches: origin when anything owns the slug, else forks. */ lookupSlug: (slug: string | null | undefined, host?: string) => Repo[] + lookupSlugMatches: (slug: string | null | undefined, host?: string) => RepoSlugMatches ready: boolean } @@ -167,45 +184,67 @@ export function useRepoSlugIndex(): RepoSlugIndexState { const repos = useAppStore((s) => s.repos) const settings = useAppStore((s) => s.settings) const [index, setIndex] = useState(() => new Map()) + const [upstreamIndex, setUpstreamIndex] = useState(() => new Map()) const [ready, setReady] = useState(false) const [retryGeneration, setRetryGeneration] = useState(0) + // Why: schedule retry in a dedicated effect so setTimeout cleanup is owned + // synchronously (react-doctor effect-needs-cleanup); async .then assignment + // was not statically owned by the buildIndex effect cleanup. + const [retryDelayMs, setRetryDelayMs] = useState(null) // Why: track the current repos snapshot so the effect can ignore stale // resolutions when repos change mid-flight. const generationRef = useRef(0) useEffect(() => { const gen = ++generationRef.current - let retryTimer: ReturnType | undefined setReady(false) - void buildIndex(repos, settings).then(({ index: next, retryDelayMs }) => { - if (gen !== generationRef.current) { - return + setRetryDelayMs(null) + void buildIndex(repos, settings).then( + ({ index: next, upstreamIndex: nextUpstream, retryDelayMs: nextRetryDelayMs }) => { + if (gen !== generationRef.current) { + return + } + setIndex(next) + setUpstreamIndex(nextUpstream) + setReady(true) + setRetryDelayMs(nextRetryDelayMs) } - setIndex(next) - setReady(true) - if (retryDelayMs !== null) { - retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) - } - }) + ) return () => { generationRef.current += 1 - if (retryTimer) { - clearTimeout(retryTimer) - } } }, [repos, retryGeneration, settings]) - return useMemo( - () => ({ + useEffect(() => { + if (retryDelayMs === null) { + return + } + const retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) + return () => { + clearTimeout(retryTimer) + } + }, [retryDelayMs]) + + return useMemo(() => { + const lookupSlugMatches = (slug: string | null | undefined, host?: string): RepoSlugMatches => { + const [owner, repo] = slug?.split('/') ?? [] + if (!owner || !repo) { + return { origin: [], upstream: [] } + } + const key = githubRepoIdentityKey({ owner, repo, host }) + return { origin: index.get(key) ?? [], upstream: upstreamIndex.get(key) ?? [] } + } + return { + lookupSlugMatches, + // Why: origin wins — when the upstream repo itself is open, a row must + // resolve to that clone rather than becoming ambiguous with someone's + // fork of it. Callers that also filter by selection use + // `lookupSlugMatches` so an unselected clone cannot hide a selected fork. lookupSlug: (slug: string | null | undefined, host?: string): Repo[] => { - const [owner, repo] = slug?.split('/') ?? [] - if (!owner || !repo) { - return [] - } - return index.get(githubRepoIdentityKey({ owner, repo, host })) ?? [] + const { origin, upstream } = lookupSlugMatches(slug, host) + return origin.length > 0 ? origin : upstream }, ready - }), - [index, ready] - ) + } + }, [index, upstreamIndex, ready]) } diff --git a/src/shared/github-repository-identity-key.ts b/src/shared/github-repository-identity-key.ts index df9b8152982..ac42fcd8ad6 100644 --- a/src/shared/github-repository-identity-key.ts +++ b/src/shared/github-repository-identity-key.ts @@ -17,3 +17,12 @@ export function githubRepoIdentityKey(repo: { const host = repo.host?.trim().toLowerCase() return host && !isDefaultGitHubHost(host) ? `${host}/${slug}` : slug } + +// Why: callers that only kept the key (not the identity it came from) still need +// its host segment to scope a second, host-less identity into the same namespace. +// `owner` and `repo` never contain `/`, so a three-segment key is host-qualified. +// `undefined` means github.com, so never pass a key that may be unresolved. +export function githubHostFromIdentityKey(key: string | null | undefined): string | undefined { + const segments = key?.split('/') ?? [] + return segments.length === 3 ? segments[0] : undefined +} From c50a75142df46c2055e3dad12b534a538244322f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:06:04 -0700 Subject: [PATCH 66/88] feat(diff): HTML preview actions in View all combined diffs (#12965) * feat(diff): HTML preview + always-visible open actions in View all Expose Open Preview to the Side for HTML sections in combined diffs when the working-tree file still exists, and keep the open-file external-link icon visible without hover. Split DiffSectionItem props/lifecycle helpers to stay under the max-lines limit. * Fix HTML preview: always-visible buttons and multi-pane group selection - Make preview buttons always visible (not hover-reveal) for touch support - Fix event propagation so clicking preview doesn't toggle sections - Use combined-diff tab's group for sourceGroupId in multi-pane layouts - Add accessibility label to open-section button - Support untracked, renamed, and uppercase HTML file extensions * fix(diff): avoid render-time ref mutation in section model lifecycle React Doctor fails static analysis when refs are written during render. Move the disposer ref sync into an effect so the stable callback-ref still disposes with the latest model paths. --- .../components/editor/CombinedDiffViewer.tsx | 44 +++++++++ .../components/editor/DiffSectionHeader.tsx | 37 ++++++- .../src/components/editor/DiffSectionItem.tsx | 97 ++++--------------- .../editor/diff-section-item-props.ts | 38 ++++++++ .../editor/diff-section-preview.test.ts | 81 ++++++++++++++++ .../components/editor/diff-section-preview.ts | 19 ++++ .../use-diff-section-model-lifecycle.ts | 42 ++++++++ 7 files changed, 276 insertions(+), 82 deletions(-) create mode 100644 src/renderer/src/components/editor/diff-section-item-props.ts create mode 100644 src/renderer/src/components/editor/diff-section-preview.test.ts create mode 100644 src/renderer/src/components/editor/diff-section-preview.ts create mode 100644 src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 2c53bd87285..f4bf62d3d54 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -13,6 +13,8 @@ import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll- import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' +import { openFilePreviewToSide } from '@/lib/file-preview' +import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' import { setWithLRU } from '@/lib/scroll-cache' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' import { findWorktreeById } from '@/store/slices/worktree-helpers' @@ -1284,6 +1286,39 @@ export default function CombinedDiffViewer({ ] ) + // Why: match single-file HTML diffs — preview the on-disk working tree file + // beside the combined view when the section is still present on disk. + const openSectionPreview = useCallback( + (section: DiffSection) => { + if ( + !canOpenDiffSectionPreviewToSide({ + path: section.path, + status: section.status, + isCommitSurface: isCommitMode + }) + ) { + return + } + // Why: use this combined-diff tab's group, not worktree activeGroupId — + // in a multi-pane layout the active group may be a different split. + const state = useAppStore.getState() + const sourceGroupId = + (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( + (tab) => + tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor') + )?.groupId ?? + activeGroupId ?? + null + openFilePreviewToSide({ + language: detectLanguage(section.path), + filePath: joinPath(file.filePath, section.path), + worktreeId: file.worktreeId, + sourceGroupId + }) + }, + [activeGroupId, file.filePath, file.id, file.worktreeId, isCommitMode] + ) + const handleSectionSave = useCallback( async (index: number) => { const section = sections[index] @@ -2024,6 +2059,15 @@ export default function CombinedDiffViewer({ openSectionTitle={ isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor' } + onOpenPreview={ + canOpenDiffSectionPreviewToSide({ + path: section.path, + status: section.status, + isCommitSurface: isCommitMode + }) + ? openSectionPreview + : undefined + } setSectionHeights={setSectionHeights} setSections={setSections} modifiedEditorsRef={modifiedEditorsRef} diff --git a/src/renderer/src/components/editor/DiffSectionHeader.tsx b/src/renderer/src/components/editor/DiffSectionHeader.tsx index 8df224e7c9d..9560ce6fcf3 100644 --- a/src/renderer/src/components/editor/DiffSectionHeader.tsx +++ b/src/renderer/src/components/editor/DiffSectionHeader.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' +import { ChevronDown, ChevronRight, ExternalLink, Eye } from 'lucide-react' import type { MouseEvent, ReactElement, ReactNode } from 'react' import { translate } from '@/i18n/i18n' @@ -11,6 +11,7 @@ export function DiffSectionHeader({ onToggle, onOpenSection, openSectionTitle, + onOpenPreview, trailingContent }: { path: string @@ -21,6 +22,7 @@ export function DiffSectionHeader({ onToggle: () => void onOpenSection: (event: MouseEvent) => void openSectionTitle: string + onOpenPreview?: (event: MouseEvent) => void trailingContent?: ReactNode }): ReactElement { return ( @@ -71,10 +73,39 @@ export function DiffSectionHeader({
{trailingContent} + {onOpenPreview != null && ( + + )} diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 8b2c143f660..c6931fe5240 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -1,12 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MutableRefObject, - type ReactNode -} from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { DiffOnMount } from '@monaco-editor/react' import type { editor as monacoEditor } from 'monaco-editor' import { monaco } from '@/lib/monaco-setup' @@ -14,26 +6,23 @@ import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { - useDiffCommentDecorator, - type DecoratedDiffComment -} from '../diff-comments/useDiffCommentDecorator' +import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { getDiffCommentPopoverLeft, getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { DiffSectionHeader } from './DiffSectionHeader' -import type { DiffSection } from './diff-section-types' import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' import { DiffSectionBody } from './DiffSectionBody' import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics' -import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal' import { getLiveDiffSectionRenderLimit } from './diff-section-live-render-limit' import { useDiffSectionFallbackCleanup } from './useDiffSectionFallbackCleanup' import { submitDiffSectionComment } from './diff-section-comment-submit' +import type { DiffSectionItemProps } from './diff-section-item-props' +import { useDiffSectionModelLifecycle } from './use-diff-section-model-lifecycle' export function DiffSectionItem({ section, @@ -49,6 +38,7 @@ export function DiffSectionItem({ toggleSection, openSection, openSectionTitle, + onOpenPreview, renderHeaderTrailingContent, onAddLineComment, addLineCommentLabel, @@ -59,42 +49,7 @@ export function DiffSectionItem({ setSections, modifiedEditorsRef, handleSectionSaveRef -}: { - section: DiffSection - index: number - isBranchMode: boolean - sideBySide: boolean - isDark: boolean - settings: { - terminalFontSize?: number - terminalFontFamily?: string - diffWordWrap?: boolean - } | null - sectionHeight: number | undefined - worktreeId?: string - loadSection: (index: number) => void - retrySection: (index: number) => void - toggleSection: (index: number) => void - openSection: (index: number) => void - openSectionTitle: string - renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode - onAddLineComment?: ( - section: DiffSection, - args: { - lineNumber: number - startLine?: number - body: string - } - ) => Promise - addLineCommentLabel?: string - addLineCommentPlaceholder?: string - inlineComments?: readonly DecoratedDiffComment[] - getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined - setSectionHeights: React.Dispatch>> - setSections: React.Dispatch> - modifiedEditorsRef: MutableRefObject> - handleSectionSaveRef: MutableRefObject<(index: number) => Promise> -}): React.JSX.Element { +}: DiffSectionItemProps): React.JSX.Element { const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const addDiffComment = useAppStore((s) => s.addDiffComment) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) @@ -137,31 +92,10 @@ export function DiffSectionItem({ } | null>(null) const hasLineCommentAction = Boolean(worktreeId || onAddLineComment) - const disposeDiffModels = useCallback(() => { - window.setTimeout(() => { - disposeUnattachedMonacoModelPaths(monaco, [ - `${modelPathBase}:original`, - `${modelPathBase}:modified` - ]) - }, 0) - }, [modelPathBase]) - const disposeDiffModelsRef = useRef(disposeDiffModels) - disposeDiffModelsRef.current = disposeDiffModels - - const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => { - if (node) { - return - } - // Why: virtualized diff rows remount as their keyed section/collapse state - // changes; the row root is the owner of the detached Monaco models. - disposeDiffModelsRef.current() - }, []) - - useEffect(() => { - if (section.collapsed) { - disposeDiffModels() - } - }, [disposeDiffModels, section.collapsed]) + const { disposeDiffModels, setSectionRootNode } = useDiffSectionModelLifecycle({ + modelPathBase, + collapsed: section.collapsed + }) // Why: only forward the pending scroll id when it matches a comment in this // section so unrelated sections don't keep re-rendering their decorator @@ -410,6 +344,13 @@ export function DiffSectionItem({ openSection(index) }} openSectionTitle={openSectionTitle} + onOpenPreview={ + onOpenPreview + ? () => { + onOpenPreview(section, index) + } + : undefined + } trailingContent={renderHeaderTrailingContent?.(section, index)} /> @@ -435,9 +376,7 @@ export function DiffSectionItem({ onCancelComment={() => setPopover(null)} onSubmitComment={handleSubmitComment} onRetrySection={retrySection} - onSaveLimitedDiff={() => { - void handleSectionSaveRef.current(index) - }} + onSaveLimitedDiff={() => void handleSectionSaveRef.current(index)} onMount={handleMount} /> )} diff --git a/src/renderer/src/components/editor/diff-section-item-props.ts b/src/renderer/src/components/editor/diff-section-item-props.ts new file mode 100644 index 00000000000..9cde9403dcf --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-item-props.ts @@ -0,0 +1,38 @@ +import type { Dispatch, MutableRefObject, ReactNode, SetStateAction } from 'react' +import type { editor as monacoEditor } from 'monaco-editor' +import type { DecoratedDiffComment } from '../diff-comments/useDiffCommentDecorator' +import type { DiffSection } from './diff-section-types' + +export type DiffSectionItemProps = { + section: DiffSection + index: number + isBranchMode: boolean + sideBySide: boolean + isDark: boolean + settings: { + terminalFontSize?: number + terminalFontFamily?: string + diffWordWrap?: boolean + } | null + sectionHeight: number | undefined + worktreeId?: string + loadSection: (index: number) => void + retrySection: (index: number) => void + toggleSection: (index: number) => void + openSection: (index: number) => void + openSectionTitle: string + onOpenPreview?: (section: DiffSection, index: number) => void + renderHeaderTrailingContent?: (section: DiffSection, index: number) => ReactNode + onAddLineComment?: ( + section: DiffSection, + args: { lineNumber: number; startLine?: number; body: string } + ) => Promise + addLineCommentLabel?: string + addLineCommentPlaceholder?: string + inlineComments?: readonly DecoratedDiffComment[] + getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined + setSectionHeights: Dispatch>> + setSections: Dispatch> + modifiedEditorsRef: MutableRefObject> + handleSectionSaveRef: MutableRefObject<(index: number) => Promise> +} diff --git a/src/renderer/src/components/editor/diff-section-preview.test.ts b/src/renderer/src/components/editor/diff-section-preview.test.ts new file mode 100644 index 00000000000..20ecbae22a2 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-preview.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' + +describe('canOpenDiffSectionPreviewToSide', () => { + it('enables HTML working-tree sections that still exist on disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'docs/demo.html', + status: 'modified', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables .htm paths', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'index.htm', + status: 'added', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables untracked and renamed HTML files still present on disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'scratch.html', + status: 'untracked', + isCommitSurface: false + }) + ).toBe(true) + expect( + canOpenDiffSectionPreviewToSide({ + path: 'renamed.html', + status: 'renamed', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('enables uppercase HTML extensions', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'Docs/DEMO.HTML', + status: 'modified', + isCommitSurface: false + }) + ).toBe(true) + }) + + it('disables deleted HTML files', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'gone.html', + status: 'deleted', + isCommitSurface: false + }) + ).toBe(false) + }) + + it('disables commit surfaces whose content may not match disk', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'docs/demo.html', + status: 'modified', + isCommitSurface: true + }) + ).toBe(false) + }) + + it('disables non-previewable languages', () => { + expect( + canOpenDiffSectionPreviewToSide({ + path: 'src/app.ts', + status: 'modified', + isCommitSurface: false + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/diff-section-preview.ts b/src/renderer/src/components/editor/diff-section-preview.ts new file mode 100644 index 00000000000..6e0fa684822 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-preview.ts @@ -0,0 +1,19 @@ +import { detectLanguage } from '@/lib/language-detect' +import { canPreviewLanguage } from '@/lib/file-preview' + +// Why: HTML preview renders the on-disk working-tree file, so combined-diff +// sections only get the affordance when that file still exists and the surface +// is not a historical commit snapshot whose content may not match disk. +export function canOpenDiffSectionPreviewToSide(params: { + path: string + status: string + isCommitSurface: boolean +}): boolean { + if (params.isCommitSurface) { + return false + } + if (params.status === 'deleted') { + return false + } + return canPreviewLanguage(detectLanguage(params.path)) +} diff --git a/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts b/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts new file mode 100644 index 00000000000..5d8c901c367 --- /dev/null +++ b/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useRef } from 'react' +import { monaco } from '@/lib/monaco-setup' +import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal' + +// Why: virtualized section rows own Monaco model paths for their lifetime; +// dispose on unmount/collapse so remounts do not leak detached models. +export function useDiffSectionModelLifecycle(params: { + modelPathBase: string + collapsed: boolean +}): { + disposeDiffModels: () => void + setSectionRootNode: (node: HTMLDivElement | null) => void +} { + const disposeDiffModels = useCallback(() => { + window.setTimeout(() => { + disposeUnattachedMonacoModelPaths(monaco, [ + `${params.modelPathBase}:original`, + `${params.modelPathBase}:modified` + ]) + }, 0) + }, [params.modelPathBase]) + const disposeDiffModelsRef = useRef(disposeDiffModels) + // Keep callback-ref dispose path on the latest disposer without render-time mutation. + useEffect(() => { + disposeDiffModelsRef.current = disposeDiffModels + }, [disposeDiffModels]) + + const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => { + if (node) { + return + } + disposeDiffModelsRef.current() + }, []) + + useEffect(() => { + if (params.collapsed) { + disposeDiffModels() + } + }, [disposeDiffModels, params.collapsed]) + + return { disposeDiffModels, setSectionRootNode } +} From f2d62a78873ca5d65b35e713c6a4b06cd5e8d2ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:14:15 -0700 Subject: [PATCH 67/88] fix(i18n): localize the status bar Resource Manager tooltip and remote-host count (#12478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): localize the status bar Resource Manager tooltip and remote-host count The Resource Manager tooltip/aria label and the SSH segment's host count were assembled from bare English literals inside helper functions, so they stayed English under every non-English UI language while the labels around them translated. Route them through the catalog with _one/_other plural keys and whole-line messages (locales reorder and repunctuate the summary), and add en/es/ja/ko/zh entries. Root cause of the miss: audit-localization-coverage bailed on any ancestor binary expression whose operator was not `+`, which hid every string under a `cond && ` guard or a `?? 'fallback'` — including this segment's 'Connecting…'. Only comparison operands are code, so keep `??`, `||` and `&&` walking, and localize the four real strings that surfaced. Co-authored-by: Orca * fix(status-bar): flag the space-scan tooltip row instead of matching its English text The tooltip tinted a row with `line === 'Space scan ready'`, so routing that copy through the catalog silently dropped the tint in every translated build. Return `{ text, emphasized }` and let the segment read the flag. Adopted from #12439 by @smwbev. Co-authored-by: Evgenii Co-authored-by: Orca * fix(status-bar): key Resource Manager tooltip rows by role instead of array index Co-authored-by: Orca --------- Co-authored-by: Orca --- config/localization-coverage-allowlist.json | 7 + .../scripts/audit-localization-coverage.mjs | 13 +- .../audit-localization-coverage.test.mjs | 44 +++++ .../settings/CliSkillRuntimeSetup.tsx | 9 +- .../status-bar/ResourceUsageStatusSegment.tsx | 9 +- .../status-bar/SshStatusSegment.tsx | 17 +- .../src/components/status-bar/StatusBar.tsx | 6 +- .../resource-manager-terminal-copy.test.ts | 57 +++++- .../resource-manager-terminal-copy.ts | 92 +++++++-- .../ssh-status-segment-copy.test.ts | 26 +++ .../status-bar/ssh-status-segment-copy.ts | 31 +++ .../status-bar-copy-localization.test.tsx | 183 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 34 +++- src/renderer/src/i18n/locales/es.json | 34 +++- src/renderer/src/i18n/locales/ja.json | 34 +++- src/renderer/src/i18n/locales/ko.json | 36 +++- src/renderer/src/i18n/locales/zh.json | 34 +++- .../src/lib/agent-skill-cli-prerequisite.ts | 9 +- src/renderer/src/lib/worktree-activation.ts | 9 +- 19 files changed, 623 insertions(+), 61 deletions(-) create mode 100644 config/scripts/audit-localization-coverage.test.mjs create mode 100644 src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts create mode 100644 src/renderer/src/components/status-bar/ssh-status-segment-copy.ts create mode 100644 src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 2a3b4603f36..b37d3374bf5 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -6,6 +6,13 @@ "dynamic": false, "count": 1 }, + { + "filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "kind": "jsx-attribute:label", + "text": "sidebar", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 8b978e38ed4..fd750a9150d 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -60,6 +60,14 @@ const USER_VISIBLE_OBJECT_METHODS = new Set([ 'warning' ]) const USER_VISIBLE_OBJECT_NAMES = new Set(['toast']) +// Why: only comparison operands are code, not copy. Bailing on every non-`+` +// operator hid whole subtrees behind `cond && ` guards and `?? 'fallback'`. +const COPY_PRESERVING_BINARY_OPERATORS = new Set([ + ts.SyntaxKind.PlusToken, + ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.AmpersandAmpersandToken +]) function normalizePath(root, filePath) { return path.relative(root, filePath).split(path.sep).join('/') @@ -226,7 +234,7 @@ function isRenderedJsxExpression(node) { continue } if (ts.isBinaryExpression(current)) { - if (current.operatorToken.kind !== ts.SyntaxKind.PlusToken) { + if (!COPY_PRESERVING_BINARY_OPERATORS.has(current.operatorToken.kind)) { return false } current = current.parent @@ -318,7 +326,8 @@ function classifyStringNode(node) { findAncestor( node, (ancestor) => - ts.isBinaryExpression(ancestor) && ancestor.operatorToken.kind !== ts.SyntaxKind.PlusToken + ts.isBinaryExpression(ancestor) && + !COPY_PRESERVING_BINARY_OPERATORS.has(ancestor.operatorToken.kind) ) ) { return undefined diff --git a/config/scripts/audit-localization-coverage.test.mjs b/config/scripts/audit-localization-coverage.test.mjs new file mode 100644 index 00000000000..291e6e29595 --- /dev/null +++ b/config/scripts/audit-localization-coverage.test.mjs @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { collectLocalizationCandidates } from './audit-localization-coverage.mjs' + +const ROOT = process.cwd() + +function candidates(fileName, source) { + return collectLocalizationCandidates(`${ROOT}/src/renderer/src/${fileName}`, source, ROOT) +} + +describe('localization coverage candidates', () => { + it('sees copy guarded by a nullish or logical fallback', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ label, connecting }) { + return {label ?? (connecting ? 'Connecting…' : 'Idle')} + }` + ) + + expect(reports.map((report) => report.text)).toEqual(['Connecting…', 'Idle']) + }) + + it('sees copy nested inside a conditional JSX guard', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ show }) { + return
{show &&
+ }` + ) + + expect(reports.map((report) => report.text)).toEqual(['Retry the sync']) + }) + + it('ignores literals used as comparison operands', () => { + const reports = candidates( + 'Sample.tsx', + `export function Sample({ phase }) { + return {phase === 'workspace conflict' ? phase : null} + }` + ) + + expect(reports).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 039c2035d59..1e8ac06c414 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -280,7 +280,14 @@ export async function ensureWslCliAvailableForAgentSkillTerminal( 'auto.components.settings.CliSkillRuntimeSetup.windowsPathUnknown', 'WSL shell command PATH could not be checked' ), - { description: status.detail ?? 'Refresh CLI registration status and try again.' } + { + description: + status.detail ?? + translate( + 'auto.components.settings.CliSkillRuntimeSetup.refreshCliRegistration', + 'Refresh CLI registration status and try again.' + ) + } ) return status } diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index fe0f5e1a297..c4690a1c79a 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -1184,12 +1184,9 @@ export function ResourceUsageStatusSegment({
- {resourceManagerTooltipLines.map((line, index) => ( -
- {line} + {resourceManagerTooltipLines.map((line) => ( +
+ {line.text}
))}
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 70e500ad09a..fc417d679f1 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -18,6 +18,11 @@ import { } from '../../../../shared/execution-host' import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-environments' import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' +import { + connectedHostCountLabel, + connectingHostsLabel, + workspaceSyncProblemLabel +} from './ssh-status-segment-copy' import { SshTargetStatusRow } from './SshTargetStatusRow' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect' @@ -59,10 +64,6 @@ function overallDotColor( } } -function connectedHostCountLabel(count: number): string { - return `${count} ${count === 1 ? 'host' : 'hosts'}` -} - function sshStatusForOverall(status: SshConnectionStatus): HostStatus { if (status === 'connected') { return 'connected' @@ -286,9 +287,7 @@ export function SshStatusSegment({ (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error' ) const syncProblemLabel = syncProblem - ? syncProblem.syncStatus?.phase === 'conflict' - ? 'Workspace conflict' - : 'Workspace sync error' + ? workspaceSyncProblemLabel(syncProblem.syncStatus?.phase) : null return ( {syncProblemLabel ?? - (anyConnecting ? 'Connecting…' : connectedHostCountLabel(connectedHostCount))} + (anyConnecting + ? connectingHostsLabel() + : connectedHostCountLabel(connectedHostCount))} )} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 9d7534e8c3b..1b3075e305b 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -2369,7 +2369,11 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele className="relative inline-flex size-5 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-secondary-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground" aria-label={ showFloatingWorkspaceAttentionDot - ? `${floatingTerminalActionLabel}, new activity` + ? translate( + 'auto.components.status.bar.StatusBar.floatingTerminalNewActivity', + '{{label}}, new activity', + { label: floatingTerminalActionLabel } + ) : floatingTerminalActionLabel } onClick={() => { diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts index 42b42d492b6..7f6d1eefa3d 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// Mirrors resource-memory-metric-copy.test.ts: assert the English source copy +// through the catalog fallback, with placeholders filled the way i18next would. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match)) +})) + import { formatTerminalSessionCount, getResourceManagerAriaLabel, @@ -19,8 +27,12 @@ describe('resource manager terminal copy', () => { spaceScanReady: false }) ).toEqual([ - 'Resource Manager - 512 MB · Σ RSS - 2 terminal sessions', - 'Terminal sessions are grouped by workspace.' + { + id: 'summary', + text: 'Resource Manager - 512 MB · Σ RSS - 2 terminal sessions', + emphasized: false + }, + { id: 'sessions-hint', text: 'Terminal sessions are grouped by workspace.', emphasized: false } ]) }) @@ -32,12 +44,45 @@ describe('resource manager terminal copy', () => { spaceScanReady: true }) ).toEqual([ - 'Resource Manager - memory unavailable - 0 terminal sessions', - 'Space scan ready', - 'No terminal sessions yet.' + { + id: 'summary', + text: 'Resource Manager - memory unavailable - 0 terminal sessions', + emphasized: false + }, + { id: 'space-scan', text: 'Space scan ready', emphasized: true }, + { id: 'sessions-hint', text: 'No terminal sessions yet.', emphasized: false } ]) }) + // Why: the tooltip used to tint this row by matching its English text, so any + // translated build lost the tint. The flag is what the segment reads now. + it('flags the space-scan row instead of leaving callers to match its wording', () => { + const lines = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 1, + spaceScanReady: true + }) + + expect(lines.filter((line) => line.emphasized).map((line) => line.text)).toEqual([ + 'Space scan ready' + ]) + }) + + // Why: the tooltip keys rows by id, so a repeated id would silently drop a row. + it('gives every tooltip row a unique id to key on', () => { + for (const spaceScanReady of [false, true]) { + for (const sessionCount of [0, 1, 4]) { + const ids = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount, + spaceScanReady + }).map((line) => line.id) + + expect(new Set(ids).size).toBe(ids.length) + } + } + }) + it('keeps the trigger label descriptive for screen readers', () => { expect( getResourceManagerAriaLabel({ diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts index 69b5b7b6193..0d082654199 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts @@ -1,30 +1,86 @@ +import { translate } from '@/i18n/i18n' + export function formatTerminalSessionCount(count: number): string { - return `${count} terminal session${count === 1 ? '' : 's'}` + return count === 1 + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.terminalSessionCount_one', + '{{count}} terminal session', + { count } + ) + : translate( + 'auto.components.status.bar.resource.manager.terminal.copy.terminalSessionCount_other', + '{{count}} terminal sessions', + { count } + ) +} + +function spaceScanReadyLabel(): string { + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.spaceScanReady', + 'Space scan ready' + ) +} + +/** + * `emphasized` marks the space-scan row the tooltip tints. The segment used to + * recognize that row by comparing it against the English text, which stops + * matching the moment the copy comes from the catalog. + * + * `id` names the row's role. Each role appears at most once per tooltip, so it is + * a unique React key that survives locale switches and memory/count updates — + * unlike the translated text, which locales are free to duplicate across rows. + */ +export type ResourceManagerTooltipLine = { + id: 'summary' | 'space-scan' | 'sessions-hint' + text: string + emphasized: boolean } export function getResourceManagerTooltipLines(args: { memoryLabel: string sessionCount: number spaceScanReady: boolean -}): string[] { +}): ResourceManagerTooltipLine[] { const rawMemoryLabel = args.memoryLabel.trim() const memoryLabel = rawMemoryLabel === '' || rawMemoryLabel === '-' || rawMemoryLabel === '—' - ? 'memory unavailable' + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.memoryUnavailable', + 'memory unavailable' + ) : rawMemoryLabel - const lines = [ - `Resource Manager - ${memoryLabel} - ${formatTerminalSessionCount(args.sessionCount)}` + // Why: whole lines are single keys — locales reorder the summary and repunctuate + // its separators, so it can't be concatenated from translated fragments here. + const lines: ResourceManagerTooltipLine[] = [ + { + id: 'summary', + text: translate( + 'auto.components.status.bar.resource.manager.terminal.copy.tooltipSummary', + 'Resource Manager - {{memory}} - {{sessions}}', + { memory: memoryLabel, sessions: formatTerminalSessionCount(args.sessionCount) } + ), + emphasized: false + } ] if (args.spaceScanReady) { - lines.push('Space scan ready') + lines.push({ id: 'space-scan', text: spaceScanReadyLabel(), emphasized: true }) } - if (args.sessionCount > 0) { - lines.push('Terminal sessions are grouped by workspace.') - } else { - lines.push('No terminal sessions yet.') - } + lines.push({ + id: 'sessions-hint', + text: + args.sessionCount > 0 + ? translate( + 'auto.components.status.bar.resource.manager.terminal.copy.sessionsGroupedByWorkspace', + 'Terminal sessions are grouped by workspace.' + ) + : translate( + 'auto.components.status.bar.resource.manager.terminal.copy.noTerminalSessions', + 'No terminal sessions yet.' + ), + emphasized: false + }) return lines } @@ -33,11 +89,19 @@ export function getResourceManagerAriaLabel(args: { sessionCount: number spaceScanReady: boolean }): string { - const parts = ['Resource Manager', formatTerminalSessionCount(args.sessionCount)] + const sessions = formatTerminalSessionCount(args.sessionCount) if (args.spaceScanReady) { - parts.push('Space scan ready') + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.ariaLabelWithSpaceScan', + 'Resource Manager, {{sessions}}, {{spaceScan}}', + { sessions, spaceScan: spaceScanReadyLabel() } + ) } - return parts.join(', ') + return translate( + 'auto.components.status.bar.resource.manager.terminal.copy.ariaLabel', + 'Resource Manager, {{sessions}}', + { sessions } + ) } diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts new file mode 100644 index 00000000000..3cd3957872f --- /dev/null +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match)) +})) + +import { + connectedHostCountLabel, + connectingHostsLabel, + workspaceSyncProblemLabel +} from './ssh-status-segment-copy' + +describe('ssh status segment copy', () => { + it('keeps the host noun singular for a lone connected host', () => { + expect(connectedHostCountLabel(0)).toBe('0 hosts') + expect(connectedHostCountLabel(1)).toBe('1 host') + expect(connectedHostCountLabel(4)).toBe('4 hosts') + }) + + it('distinguishes a sync conflict from a sync failure', () => { + expect(connectingHostsLabel()).toBe('Connecting…') + expect(workspaceSyncProblemLabel('conflict')).toBe('Workspace conflict') + expect(workspaceSyncProblemLabel('error')).toBe('Workspace sync error') + }) +}) diff --git a/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts new file mode 100644 index 00000000000..cf4cd7e3552 --- /dev/null +++ b/src/renderer/src/components/status-bar/ssh-status-segment-copy.ts @@ -0,0 +1,31 @@ +import { translate } from '@/i18n/i18n' + +export function connectedHostCountLabel(count: number): string { + return count === 1 + ? translate( + 'auto.components.status.bar.SshStatusSegment.connectedHostCount_one', + '{{count}} host', + { count } + ) + : translate( + 'auto.components.status.bar.SshStatusSegment.connectedHostCount_other', + '{{count}} hosts', + { count } + ) +} + +export function connectingHostsLabel(): string { + return translate('auto.components.status.bar.SshStatusSegment.connecting', 'Connecting…') +} + +export function workspaceSyncProblemLabel(phase: string | undefined): string { + return phase === 'conflict' + ? translate( + 'auto.components.status.bar.SshStatusSegment.workspaceConflict', + 'Workspace conflict' + ) + : translate( + 'auto.components.status.bar.SshStatusSegment.workspaceSyncError', + 'Workspace sync error' + ) +} diff --git a/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx new file mode 100644 index 00000000000..96da506d9e9 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-copy-localization.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment happy-dom +/** + * The Resource Manager tooltip and the SSH segment's host count were built from + * bare English literals inside helper functions, so they stayed English while + * every label around them translated. The coverage audit cannot see values + * returned from helpers, so only a runtime assertion against the real catalog + * keeps them honest — same reasoning as + * `src/renderer/src/i18n/settings-status-label-localization.test.ts`. + */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { i18n } from '@/i18n/i18n' +import { + formatTerminalSessionCount, + getResourceManagerAriaLabel, + getResourceManagerTooltipLines +} from './resource-manager-terminal-copy' +import { SshStatusSegment } from './SshStatusSegment' + +type StoreState = { + sshConnectionStates: Map + sshTargetLabels: Map + remoteWorkspaceSyncStatusByTargetId: Record +} + +let storeState: StoreState = { + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + remoteWorkspaceSyncStatusByTargetId: {} +} + +vi.mock('../../store', () => { + const state = (): Record => ({ + ...storeState, + settings: null, + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + setRuntimeEnvironmentStatus: vi.fn(), + hydrateRuntimeEnvironmentStatuses: vi.fn(), + setActiveView: vi.fn(), + openSettingsTarget: vi.fn(), + recordFeatureInteraction: vi.fn(), + fetchRuntimeEnvironmentRepos: vi.fn(), + fetchWorktrees: vi.fn(), + fetchWorktreeLineage: vi.fn() + }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +function setSshTargets( + entries: { id: string; label: string; status: SshConnectionStatus; syncPhase?: string }[] +): void { + storeState = { + sshConnectionStates: new Map(entries.map((entry) => [entry.id, { status: entry.status }])), + sshTargetLabels: new Map(entries.map((entry) => [entry.id, entry.label])), + remoteWorkspaceSyncStatusByTargetId: Object.fromEntries( + entries.flatMap((entry) => (entry.syncPhase ? [[entry.id, { phase: entry.syncPhase }]] : [])) + ) + } +} + +function triggerText(): string { + return screen.getByRole('button').textContent ?? '' +} + +describe('status-bar copy under a non-English UI language', () => { + beforeAll(async () => { + await i18n.changeLanguage('ja') + }) + + afterEach(() => { + cleanup() + }) + + afterAll(async () => { + await i18n.changeLanguage('en') + }) + + it('translates both plural forms of the terminal session count', () => { + expect(formatTerminalSessionCount(1)).toBe('1 件のターミナルセッション') + expect(formatTerminalSessionCount(5)).toBe('5 件のターミナルセッション') + }) + + it('translates every Resource Manager tooltip line', () => { + expect( + getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 2, + spaceScanReady: true + }) + ).toEqual([ + { + id: 'summary', + text: 'リソースマネージャー - 512 MB - 2 件のターミナルセッション', + emphasized: false + }, + { id: 'space-scan', text: '容量スキャンの準備完了', emphasized: true }, + { + id: 'sessions-hint', + text: 'ターミナルセッションはワークスペースごとにグループ化されます。', + emphasized: false + } + ]) + }) + + // Why: the tint used to be selected by `line === 'Space scan ready'`, so it + // silently vanished for every non-English locale once the copy translated. + it('keeps the space-scan row flagged when its copy is no longer English', () => { + const lines = getResourceManagerTooltipLines({ + memoryLabel: '512 MB', + sessionCount: 2, + spaceScanReady: true + }) + + const emphasized = lines.filter((line) => line.emphasized) + expect(emphasized).toHaveLength(1) + expect(emphasized[0]?.text).toBe('容量スキャンの準備完了') + expect(emphasized[0]?.text).not.toBe('Space scan ready') + }) + + it('translates the memory-unavailable and empty-session tooltip lines', () => { + expect( + getResourceManagerTooltipLines({ memoryLabel: '—', sessionCount: 0, spaceScanReady: false }) + ).toEqual([ + { + id: 'summary', + text: 'リソースマネージャー - メモリ情報を取得できません - 0 件のターミナルセッション', + emphasized: false + }, + { id: 'sessions-hint', text: 'ターミナルセッションはまだありません。', emphasized: false } + ]) + }) + + it('translates the Resource Manager trigger label read by screen readers', () => { + expect(getResourceManagerAriaLabel({ sessionCount: 1, spaceScanReady: true })).toBe( + 'リソースマネージャー、1 件のターミナルセッション、容量スキャンの準備完了' + ) + expect(getResourceManagerAriaLabel({ sessionCount: 3, spaceScanReady: false })).toBe( + 'リソースマネージャー、3 件のターミナルセッション' + ) + }) + + it('translates the connected host count next to the translated aria label', () => { + setSshTargets([ + { id: 'ssh-1', label: 'builder', status: 'connected' }, + { id: 'ssh-2', label: 'openclaw', status: 'connected' } + ]) + render() + + expect(screen.getByRole('button').getAttribute('aria-label')).toBe('リモートホスト接続状態') + expect(triggerText()).toContain('2 台のホスト') + }) + + it('translates the singular host count', () => { + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connected' }]) + render() + + expect(triggerText()).toContain('1 台のホスト') + }) + + it('translates the connecting and workspace-sync states', () => { + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connecting' }]) + render() + expect(triggerText()).toContain('接続中…') + cleanup() + + setSshTargets([ + { id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'conflict' }, + { id: 'ssh-2', label: 'openclaw', status: 'connected', syncPhase: 'error' } + ]) + render() + expect(triggerText()).toContain('ワークスペースの競合') + cleanup() + + setSshTargets([{ id: 'ssh-1', label: 'builder', status: 'connected', syncPhase: 'error' }]) + render() + expect(triggerText()).toContain('ワークスペースの同期エラー') + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b25a212f7a2..467ca68d5aa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -415,7 +415,8 @@ "8d6eedf97e": "Failed to register the Orca CLI in PATH.", "0f116999f1": "Restart your shell or add the Orca CLI directory to PATH before setup.", "15cbedc3e3": "Install the Orca CLI before running agent skill setup.", - "windowsPathUnknown": "Orca could not check your Windows user PATH" + "windowsPathUnknown": "Orca could not check your Windows user PATH", + "refreshCliRegistration": "Refresh CLI registration status and try again." } } } @@ -631,6 +632,9 @@ "0b01ff98d2": "Port", "7d732521ec": "Comment" } + }, + "activation": { + "cannotOpenFolderWorkspace": "Cannot open folder workspace" } }, "folderWorkspacePathStatus": { @@ -3262,7 +3266,12 @@ "runtime_disconnect_failed": "Disconnect failed", "runtime_reconnecting": "Reconnecting", "runtime_last_close_reason": "Closed: {{value0}}", - "runtime_reconnect_attempt": "Attempt {{value0}}" + "runtime_reconnect_attempt": "Attempt {{value0}}", + "connectedHostCount_one": "{{count}} host", + "connectedHostCount_other": "{{count}} hosts", + "connecting": "Connecting…", + "workspaceConflict": "Workspace conflict", + "workspaceSyncError": "Workspace sync error" }, "StatusBar": { "9659e38343": "Ports", @@ -3322,7 +3331,8 @@ "antigravityUsage": "Antigravity Usage", "antigravityUsageDetails": "Open Antigravity usage details", "grokUsageAria": "Open Grok usage details", - "grokUsageMenu": "Grok Usage" + "grokUsageMenu": "Grok Usage", + "floatingTerminalNewActivity": "{{label}}, new activity" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3548,6 +3558,21 @@ "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." } + }, + "manager": { + "terminal": { + "copy": { + "terminalSessionCount_one": "{{count}} terminal session", + "terminalSessionCount_other": "{{count}} terminal sessions", + "memoryUnavailable": "memory unavailable", + "tooltipSummary": "Resource Manager - {{memory}} - {{sessions}}", + "spaceScanReady": "Space scan ready", + "sessionsGroupedByWorkspace": "Terminal sessions are grouped by workspace.", + "noTerminalSessions": "No terminal sessions yet.", + "ariaLabel": "Resource Manager, {{sessions}}", + "ariaLabelWithSpaceScan": "Resource Manager, {{sessions}}, {{spaceScan}}" + } + } } } } @@ -5792,7 +5817,8 @@ "f00d6aa9b5": "WSL is not available on this machine.", "7c776ff9d8": "wsl", "fc0fcf72fd": "Register the WSL shell command before skill setup.", - "windowsPathUnknown": "WSL shell command PATH could not be checked" + "windowsPathUnknown": "WSL shell command PATH could not be checked", + "refreshCliRegistration": "Refresh CLI registration status and try again." }, "CommitMessageAiPane": { "841ed9884a": "Used by repositories that have not customized Source Control AI.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ac0f5c012bf..e60f92ba08d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -349,7 +349,8 @@ "8d6eedf97e": "No se pudo registrar Orca CLI en PATH.", "0f116999f1": "Reinicia tu shell o agrega el directorio de Orca CLI a PATH antes de la configuración.", "15cbedc3e3": "Instala la CLI de Orca antes de configurar skills para agentes.", - "windowsPathUnknown": "Orca could not check your Windows user PATH" + "windowsPathUnknown": "Orca could not check your Windows user PATH", + "refreshCliRegistration": "Actualiza el estado de registro de la CLI e inténtalo de nuevo." } } } @@ -560,6 +561,9 @@ "0b01ff98d2": "Puerto", "7d732521ec": "Comentario" } + }, + "activation": { + "cannotOpenFolderWorkspace": "No se puede abrir el espacio de trabajo de carpeta" } }, "folderWorkspacePathStatus": { @@ -3174,7 +3178,12 @@ "runtime_disconnect_failed": "No se pudo desconectar", "runtime_reconnecting": "Reconectando", "runtime_last_close_reason": "Cerrado: {{value0}}", - "runtime_reconnect_attempt": "Intento {{value0}}" + "runtime_reconnect_attempt": "Intento {{value0}}", + "connectedHostCount_one": "{{count}} host", + "connectedHostCount_other": "{{count}} hosts", + "connecting": "Conectando…", + "workspaceConflict": "Conflicto del espacio de trabajo", + "workspaceSyncError": "Error de sincronización del espacio de trabajo" }, "StatusBar": { "9659e38343": "Puertos", @@ -3234,7 +3243,8 @@ "antigravityUsage": "Uso de Antigravity", "antigravityUsageDetails": "Abrir detalles de uso de Antigravity", "grokUsageAria": "Abrir detalles de uso de Grok", - "grokUsageMenu": "Uso de Grok" + "grokUsageMenu": "Uso de Grok", + "floatingTerminalNewActivity": "{{label}}, nueva actividad" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -3444,6 +3454,21 @@ "workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.", "rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso." } + }, + "manager": { + "terminal": { + "copy": { + "terminalSessionCount_one": "{{count}} sesión de terminal", + "terminalSessionCount_other": "{{count}} sesiones de terminal", + "memoryUnavailable": "memoria no disponible", + "tooltipSummary": "Administrador de recursos - {{memory}} - {{sessions}}", + "spaceScanReady": "Escaneo de espacio listo", + "sessionsGroupedByWorkspace": "Las sesiones de terminal se agrupan por espacio de trabajo.", + "noTerminalSessions": "Aún no hay sesiones de terminal.", + "ariaLabel": "Administrador de recursos, {{sessions}}", + "ariaLabelWithSpaceScan": "Administrador de recursos, {{sessions}}, {{spaceScan}}" + } + } } }, "SkillUpdateStatusSegment": { @@ -5624,7 +5649,8 @@ "f00d6aa9b5": "WSL no está disponible en esta máquina.", "7c776ff9d8": "wsl", "fc0fcf72fd": "Registra el comando de shell de WSL antes de configurar el skill.", - "windowsPathUnknown": "WSL shell command PATH could not be checked" + "windowsPathUnknown": "WSL shell command PATH could not be checked", + "refreshCliRegistration": "Actualiza el estado de registro de la CLI e inténtalo de nuevo." }, "CommitMessageAiPane": { "841ed9884a": "Usado por repositorios que no han personalizado Source Control AI.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d239b240df0..f008abfa70e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -349,7 +349,8 @@ "8d6eedf97e": "Orca CLI を PATH に登録できませんでした。", "0f116999f1": "セットアップ前にシェルを再起動するか、Orca CLI ディレクトリを PATH に追加します。", "15cbedc3e3": "agent スキル セットアップを実行する前に、Orca CLI をインストールします。", - "windowsPathUnknown": "Orca could not check your Windows user PATH" + "windowsPathUnknown": "Orca could not check your Windows user PATH", + "refreshCliRegistration": "CLI の登録状態を更新してから、もう一度お試しください。" } } } @@ -560,6 +561,9 @@ "0b01ff98d2": "ポート", "7d732521ec": "コメント" } + }, + "activation": { + "cannotOpenFolderWorkspace": "フォルダーワークスペースを開けません" } }, "folderWorkspacePathStatus": { @@ -3174,7 +3178,12 @@ "runtime_disconnect_failed": "切断に失敗しました", "runtime_reconnecting": "再接続中", "runtime_last_close_reason": "クローズ: {{value0}}", - "runtime_reconnect_attempt": "試行 {{value0}}" + "runtime_reconnect_attempt": "試行 {{value0}}", + "connectedHostCount_one": "{{count}} 台のホスト", + "connectedHostCount_other": "{{count}} 台のホスト", + "connecting": "接続中…", + "workspaceConflict": "ワークスペースの競合", + "workspaceSyncError": "ワークスペースの同期エラー" }, "StatusBar": { "9659e38343": "ポート", @@ -3234,7 +3243,8 @@ "antigravityUsage": "Antigravity の使用状況", "antigravityUsageDetails": "Antigravity の使用状況詳細を開く", "grokUsageAria": "Grok の使用状況詳細を開く", - "grokUsageMenu": "Grok の使用状況" + "grokUsageMenu": "Grok の使用状況", + "floatingTerminalNewActivity": "{{label}}、新しいアクティビティ" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3444,6 +3454,21 @@ "workingSetDescription": "合計ワーキングセット(WS)。共有ページが複数のプロセスに表示されることがあります。", "rssDescription": "合計レジデントセットサイズ(RSS)。共有またはエイリアスされたページが複数のプロセスに表示されることがあります。" } + }, + "manager": { + "terminal": { + "copy": { + "terminalSessionCount_one": "{{count}} 件のターミナルセッション", + "terminalSessionCount_other": "{{count}} 件のターミナルセッション", + "memoryUnavailable": "メモリ情報を取得できません", + "tooltipSummary": "リソースマネージャー - {{memory}} - {{sessions}}", + "spaceScanReady": "容量スキャンの準備完了", + "sessionsGroupedByWorkspace": "ターミナルセッションはワークスペースごとにグループ化されます。", + "noTerminalSessions": "ターミナルセッションはまだありません。", + "ariaLabel": "リソースマネージャー、{{sessions}}", + "ariaLabelWithSpaceScan": "リソースマネージャー、{{sessions}}、{{spaceScan}}" + } + } } }, "SkillUpdateStatusSegment": { @@ -5609,7 +5634,8 @@ "f00d6aa9b5": "このマシンでは WSL を利用できません。", "7c776ff9d8": "wsl", "fc0fcf72fd": "スキルセットアップ前に WSL シェルコマンドを登録してください。", - "windowsPathUnknown": "WSL shell command PATH could not be checked" + "windowsPathUnknown": "WSL shell command PATH could not be checked", + "refreshCliRegistration": "CLI の登録状態を更新してから、もう一度お試しください。" }, "CommitMessageAiPane": { "841ed9884a": "ソース管理 AI をカスタマイズしていない repos によって使用されます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 77f37c36a19..1b4c5c4fba2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -349,7 +349,8 @@ "8d6eedf97e": "PATH에 Orca CLI를 등록하지 못했습니다.", "0f116999f1": "설정하기 전에 셸을 다시 시작하거나 Orca CLI 디렉터리를 PATH에 추가하세요.", "15cbedc3e3": "agent 스킬 설정을 실행하기 전에 Orca CLI를 설치하십시오.", - "windowsPathUnknown": "Orca could not check your Windows user PATH" + "windowsPathUnknown": "Orca could not check your Windows user PATH", + "refreshCliRegistration": "CLI 등록 상태를 새로 고친 후 다시 시도하세요." } } } @@ -560,6 +561,9 @@ "0b01ff98d2": "포트", "7d732521ec": "댓글" } + }, + "activation": { + "cannotOpenFolderWorkspace": "폴더 워크스페이스를 열 수 없습니다" } }, "folderWorkspacePathStatus": { @@ -3174,7 +3178,12 @@ "runtime_disconnect_failed": "연결 해제 실패", "runtime_reconnecting": "다시 연결 중", "runtime_last_close_reason": "닫힘: {{value0}}", - "runtime_reconnect_attempt": "{{value0}}번째 시도" + "runtime_reconnect_attempt": "{{value0}}번째 시도", + "connectedHostCount_one": "호스트 {{count}}개", + "connectedHostCount_other": "호스트 {{count}}개", + "connecting": "연결 중…", + "workspaceConflict": "워크스페이스 충돌", + "workspaceSyncError": "워크스페이스 동기화 오류" }, "StatusBar": { "9659e38343": "포트", @@ -3234,7 +3243,8 @@ "antigravityUsage": "Antigravity 사용량", "antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기", "grokUsageAria": "Grok 사용량 세부 정보 열기", - "grokUsageMenu": "Grok 사용량" + "grokUsageMenu": "Grok 사용량", + "floatingTerminalNewActivity": "{{label}}, 새 활동" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3444,6 +3454,21 @@ "workingSetDescription": "합산된 워킹 세트(WS). 공유 페이지가 둘 이상의 프로세스에 나타날 수 있습니다.", "rssDescription": "합산된 레지던트 세트 크기(RSS). 공유 또는 별칭된 페이지가 둘 이상의 프로세스에 나타날 수 있습니다." } + }, + "manager": { + "terminal": { + "copy": { + "terminalSessionCount_one": "터미널 세션 {{count}}개", + "terminalSessionCount_other": "터미널 세션 {{count}}개", + "memoryUnavailable": "메모리 정보 없음", + "tooltipSummary": "리소스 관리자 - {{memory}} - {{sessions}}", + "spaceScanReady": "공간 스캔 준비됨", + "sessionsGroupedByWorkspace": "터미널 세션은 워크스페이스별로 그룹화됩니다.", + "noTerminalSessions": "아직 터미널 세션이 없습니다.", + "ariaLabel": "리소스 관리자, {{sessions}}", + "ariaLabelWithSpaceScan": "리소스 관리자, {{sessions}}, {{spaceScan}}" + } + } } }, "SkillUpdateStatusSegment": { @@ -5608,8 +5633,9 @@ "0c9f3cf9da": "Orca가 글로벌 agent 스킬을 확인하고 설치하는 위치를 선택합니다.", "f00d6aa9b5": "이 컴퓨터에서는 WSL을 사용할 수 없습니다.", "7c776ff9d8": "wsl", - "fc0fcf72fd": "스킬 설정 전 WSL 셸 명령어를 등록하세요.", - "windowsPathUnknown": "WSL shell command PATH could not be checked" + "fc0fcf72fd": "스킬 설정 전 WSL 쉘 명령어를 등록하세요.", + "windowsPathUnknown": "WSL shell command PATH could not be checked", + "refreshCliRegistration": "CLI 등록 상태를 새로 고친 후 다시 시도하세요." }, "CommitMessageAiPane": { "841ed9884a": "소스 제어 AI를 사용자 정의하지 않은 리포지토리에서 사용됩니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b71ed76c0e6..90628fb006f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -349,7 +349,8 @@ "8d6eedf97e": "无法在 PATH 中注册 Orca CLI。", "0f116999f1": "在安装之前重新启动 shell 或将 Orca CLI 目录添加到 PATH。", "15cbedc3e3": "在运行智能体技能设置之前安装 Orca CLI。", - "windowsPathUnknown": "Orca 无法检查您的 Windows 用户 PATH" + "windowsPathUnknown": "Orca 无法检查您的 Windows 用户 PATH", + "refreshCliRegistration": "请刷新 CLI 注册状态后重试。" } } } @@ -560,6 +561,9 @@ "0b01ff98d2": "端口", "7d732521ec": "评论" } + }, + "activation": { + "cannotOpenFolderWorkspace": "无法打开文件夹工作区" } }, "folderWorkspacePathStatus": { @@ -3186,7 +3190,12 @@ "runtime_disconnect_failed": "断开连接失败", "runtime_reconnecting": "重新连接中", "runtime_last_close_reason": "已关闭:{{value0}}", - "runtime_reconnect_attempt": "第 {{value0}} 次尝试" + "runtime_reconnect_attempt": "第 {{value0}} 次尝试", + "connectedHostCount_one": "{{count}} 个主机", + "connectedHostCount_other": "{{count}} 个主机", + "connecting": "正在连接…", + "workspaceConflict": "工作区冲突", + "workspaceSyncError": "工作区同步错误" }, "StatusBar": { "9659e38343": "端口", @@ -3246,7 +3255,8 @@ "antigravityUsage": "Antigravity 使用量", "antigravityUsageDetails": "打开 Antigravity 使用详情", "grokUsageAria": "打开 Grok 使用详情", - "grokUsageMenu": "Grok 使用量" + "grokUsageMenu": "Grok 使用量", + "floatingTerminalNewActivity": "{{label}},有新活动" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3456,6 +3466,21 @@ "workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。", "rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。" } + }, + "manager": { + "terminal": { + "copy": { + "terminalSessionCount_one": "{{count}} 个终端会话", + "terminalSessionCount_other": "{{count}} 个终端会话", + "memoryUnavailable": "内存信息不可用", + "tooltipSummary": "资源管理器 - {{memory}} - {{sessions}}", + "spaceScanReady": "空间扫描已就绪", + "sessionsGroupedByWorkspace": "终端会话按工作区分组。", + "noTerminalSessions": "暂无终端会话。", + "ariaLabel": "资源管理器,{{sessions}}", + "ariaLabelWithSpaceScan": "资源管理器,{{sessions}},{{spaceScan}}" + } + } } }, "SkillUpdateStatusSegment": { @@ -5621,7 +5646,8 @@ "f00d6aa9b5": "WSL 在此计算机上不可用。", "7c776ff9d8": "wsl", "fc0fcf72fd": "在技​​能设置之前注册 WSL shell 命令。", - "windowsPathUnknown": "无法检查 WSL shell 命令 PATH" + "windowsPathUnknown": "无法检查 WSL shell 命令 PATH", + "refreshCliRegistration": "请刷新 CLI 注册状态后重试。" }, "CommitMessageAiPane": { "841ed9884a": "由未自定义源代码控制 AI 的存储库使用。", diff --git a/src/renderer/src/lib/agent-skill-cli-prerequisite.ts b/src/renderer/src/lib/agent-skill-cli-prerequisite.ts index 1c731bae1fe..aa02dec7460 100644 --- a/src/renderer/src/lib/agent-skill-cli-prerequisite.ts +++ b/src/renderer/src/lib/agent-skill-cli-prerequisite.ts @@ -117,7 +117,14 @@ function showCliPrerequisiteWarning(status: CliInstallStatus): void { 'auto.lib.agent.skill.cli.prerequisite.windowsPathUnknown', 'Orca could not check your Windows user PATH' ), - { description: status.detail ?? 'Refresh CLI registration status and try again.' } + { + description: + status.detail ?? + translate( + 'auto.lib.agent.skill.cli.prerequisite.refreshCliRegistration', + 'Refresh CLI registration status and try again.' + ) + } ) return } diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index c5b57fd1a9d..7e317024123 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -19,6 +19,7 @@ import { buildSetupRunnerCommand } from './setup-runner' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' import { agentKindToTuiAgent } from '../../../shared/agent-kind' +import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { tabHasLivePty } from '@/lib/tab-has-live-pty' @@ -231,7 +232,13 @@ export function activateAndRevealFolderWorkspace( { runtimeEnvironmentId } ) if (folderWorkspaceActivationBlocked(pathStatus)) { - toast.error(getFolderWorkspacePathStatusTitle(pathStatus) ?? 'Cannot open folder workspace', { + const title = + getFolderWorkspacePathStatusTitle(pathStatus) ?? + translate( + 'auto.lib.worktree.activation.cannotOpenFolderWorkspace', + 'Cannot open folder workspace' + ) + toast.error(title, { description: getFolderWorkspacePathStatusDescription(pathStatus) ?? folderWorkspace.folderPath }) return false From cb960408f26e8643e2ab58cac48ae1e4db15d5de Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:17:43 -0700 Subject: [PATCH 68/88] fix(mobile): never auto-advertise virtual bridge addresses for pairing (#12962) * fix(mobile): never auto-advertise virtual bridge addresses for pairing Container/VM bridges stay manually pickable, but automatic defaults skip them so QR codes do not race an unreachable direct path. Relay pairs without a local address; LAN-only and runtime pairing fail closed on bridge-only hosts. * fix(mobile): never auto-advertise virtual bridge addresses for pairing - Set endpoint to null when no direct address is advertised, so the QR doesn't show an unreachable address to the scanning phone - Distinguish "No address selected" (bridge exists but not advertised) from "No interfaces found" (genuinely nothing to pick) - Add tests for NetworkInterfacePicker placeholder behavior --- src/main/ipc/mobile.test.ts | 98 +++++++++++++++++++ src/main/ipc/mobile.ts | 36 +++---- src/preload/api-types.ts | 3 +- src/preload/index.ts | 3 +- .../mobile/NetworkInterfacePicker.test.tsx | 52 ++++++++++ .../mobile/NetworkInterfacePicker.tsx | 17 +++- ...mobile-network-interface-selection.test.ts | 15 +++ .../mobile-network-interface-selection.ts | 9 +- src/renderer/src/i18n/locales/en.json | 1 + src/shared/pairing-address-auto-selection.ts | 32 ++++++ 10 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx create mode 100644 src/shared/pairing-address-auto-selection.ts diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index bc90f0b2b41..51ce4a6c9f1 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -171,6 +171,87 @@ describe('registerMobileHandlers', () => { }) }) + it('never auto-advertises a bridge: a bridge-only host pairs over Relay with no address', async () => { + // Why: a bridge address the phone provably cannot reach must not become the default, and Relay + // needs no local address — so the QR ships without a direct path instead of an unreachable one. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], + 'vEthernet (WSL)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#relay', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'mobile-bridge-only', + connectionMode: 'automatic' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ + available: true, + connectionMode: 'automatic', + // Why: the offer's loopback fallback points at the scanning phone, not this host — reporting it + // would print a direct endpoint under the QR that nothing can dial. + endpoint: null + }) + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: null }) + ) + // The bridges stay pickable, just never automatically. + expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({ + interfaces: [ + { name: 'docker0', address: '172.17.0.1' }, + { name: 'vEthernet (WSL)', address: '172.28.80.1' } + ] + }) + }) + + it('refuses a LAN-only QR on a bridge-only host instead of advertising the bridge', async () => { + // Why: LAN has no Relay to fall back on, so a dead direct endpoint is worse than saying so — + // the guidance points at the picker, where the bridge is still selectable on purpose. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] + }) + const createMobilePairingOffer = vi.fn() + + registerMobileHandlers({ createMobilePairingOffer } as never) + + await expect( + handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) + ).resolves.toMatchObject({ + available: false, + reason: 'invalid_advertised_endpoint' + }) + expect(createMobilePairingOffer).not.toHaveBeenCalled() + }) + + it('honors an explicitly picked bridge address', async () => { + // Why: exclusion is about the automatic default only — a user who knows their bridge is routable + // (a VM guest pairing with the host) must still be able to advertise it. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }] + }) + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#bridge', + endpoint: 'ws://172.17.0.1:6768', + deviceId: 'mobile-bridge-pick', + connectionMode: 'local-only' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never) + await handlers.get('mobile:getPairingQR')?.(null, { + address: '172.17.0.1', + connectionMode: 'local-only' + }) + + expect(createMobilePairingOffer).toHaveBeenCalledWith( + expect.objectContaining({ address: '172.17.0.1' }) + ) + }) + it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => { networkInterfacesMock.mockReturnValue({ eth0: [ @@ -388,6 +469,23 @@ describe('registerMobileHandlers', () => { ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) }) + it('reports runtime pairing unavailable rather than advertising a bridge', async () => { + // Why: runtime clients have no Relay fallback, so a bridge-only host has nothing reachable to + // advertise — and no widen should happen for a link that would be dead anyway. + networkInterfacesMock.mockReturnValue({ + docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] + }) + const rpcServer = stubRuntimePairingServer('172.17.0.1:6768') + + registerMobileHandlers(rpcServer as never) + + await expect(handlers.get('mobile:getRuntimePairingUrl')?.(null, {})).resolves.toEqual({ + available: false + }) + expect(rpcServer.createPairingOffer).not.toHaveBeenCalled() + expect(rpcServer.ensureNetworkExposure).not.toHaveBeenCalled() + }) + // Why: every loopback form the address field accepts must behave identically once the user declared // "This computer only" — `splitHostPort`/`ws://` overrides all reach the handler verbatim, so gating on // the raw string alone treated `localhost:8443` and `ws://127.0.0.1:6768` as off-host. diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 0b391acc71a..637d579cae8 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,6 +2,10 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import { + isVirtualBridgeInterface, + selectAutoAdvertisedPairingAddress +} from '../../shared/pairing-address-auto-selection' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { isTailnetIPv4Address } from '../../shared/tailnet-address' @@ -35,19 +39,6 @@ function isProxyFakeIpIPv4Address(address: string): boolean { return /^198\.(?:18|19)\./.test(address) } -// Why: container/VM bridges are host-local — a phone can never reach docker0 or -// vmnet8 — but they enumerate as ordinary non-internal IPv4, so advertising one -// makes the direct path silently lose the pairing race and every session relay. -// Keyed on interface name, not subnet: Docker's 172.16/12 pool overlaps real -// corporate LANs, so an address test would demote genuine addresses. These stay -// pickable in the UI; they are only ranked below a real LAN address. -const VIRTUAL_BRIDGE_INTERFACE_PATTERN = - /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|^vEthernet |VMware Network Adapter|VirtualBox Host-Only/i - -function isVirtualBridgeInterface(name: string): boolean { - return VIRTUAL_BRIDGE_INTERFACE_PATTERN.test(name) -} - // Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't // connectable from a mobile device. We enumerate all non-internal IPv4 and // (non-link-local) IPv6 addresses so the user can choose which one to advertise @@ -78,7 +69,8 @@ function getNetworkInterfaces(): NetworkInterface[] { } // Why: prefer tailnet IPv4 first (most portable across networks), then other // IPv4, then IPv6 as a fallback for IPv6-only environments. Virtual bridges - // sort below both so they are never the auto-advertised default. + // sort below all of them and stay in the list so they remain explicitly + // pickable; the automatic default skips them entirely. return result.sort((a, b) => rankInterface(a) - rankInterface(b)) } @@ -90,9 +82,10 @@ function rankInterface({ name, address }: NetworkInterface): number { return (address.includes(':') ? 2 : 1) + bridgePenalty } +// Why: null when every enumerated interface is a host-local bridge, so a docker0-only host +// advertises no direct address instead of one the phone provably cannot reach. function getDefaultPairingAddress(): string | null { - const ifaces = getNetworkInterfaces() - return ifaces.length > 0 ? ifaces[0]!.address : null + return selectAutoAdvertisedPairingAddress(getNetworkInterfaces()) ?? null } // Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it @@ -156,7 +149,11 @@ export function registerMobileHandlers( // embed in the QR code. This supports overlay networks (Tailscale, // ZeroTier) where the default LAN IP isn't reachable from the phone. const ip = args?.address ?? getDefaultPairingAddress() - if (!ip) { + // Why: the local address is optional under Relay — the QR carries the relay invite, so a host + // with nothing auto-advertisable (only container bridges, or no interface at all) still pairs. + // The offer's endpoint then falls back to loopback, which is the phone's own device: the direct + // candidate loses the race by construction. LAN-only has no relay to fall back on, so it fails closed. + if (!ip && args?.connectionMode === 'local-only') { return { available: false as const, reason: 'invalid_advertised_endpoint', @@ -196,7 +193,10 @@ export function registerMobileHandlers( qrDataUrl: qr.ok ? qr.qrDataUrl : null, ...(!qr.ok ? { qrError: qr.reason } : {}), pairingUrl: offer.pairingUrl, - endpoint: offer.endpoint, + // Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at + // whichever device scans the QR — never this host. Report no endpoint so the UI omits it + // instead of printing an address the phone can't reach. + endpoint: ip ? offer.endpoint : null, deviceId: offer.deviceId, connectionMode: offer.connectionMode } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a6c38a400a3..b1d9feb03c7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3678,7 +3678,8 @@ export type PreloadApi = { qrDataUrl: string | null qrError?: 'encoding_failed' pairingUrl: string - endpoint: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null deviceId: string /** Mode the QR actually encodes. */ connectionMode: MobilePairingConnectionMode diff --git a/src/preload/index.ts b/src/preload/index.ts index e2bc12ab4a9..34e195f3848 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4690,7 +4690,8 @@ const api = { qrDataUrl: string | null qrError?: 'encoding_failed' pairingUrl: string - endpoint: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null deviceId: string connectionMode: MobilePairingConnectionMode } diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx new file mode 100644 index 00000000000..6cddebcad90 --- /dev/null +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NetworkInterfacePicker } from './NetworkInterfacePicker' + +afterEach(cleanup) + +const noop = vi.fn() + +function renderPicker(props: { + networkInterfaces?: { name: string; address: string }[] + customAddresses?: string[] + selectedAddress?: string +}): void { + render( + + ) +} + +describe('NetworkInterfacePicker', () => { + // Why: a bridge-only host auto-advertises nothing, so the trigger has no selection to show while the + // bridge is still listed inside. Claiming "No interfaces found" there contradicts the list itself. + it('does not claim there are no interfaces when unselected options exist', () => { + renderPicker({ networkInterfaces: [{ name: 'docker0', address: '172.17.0.1' }] }) + + expect(screen.getByRole('combobox').textContent).toContain('No address selected') + }) + + it('reports an empty interface list when there is genuinely nothing to pick', () => { + renderPicker({}) + + expect(screen.getByRole('combobox').textContent).toContain('No interfaces found') + }) + + it('shows the selected interface instead of any placeholder', () => { + renderPicker({ + networkInterfaces: [{ name: 'en0', address: '192.168.1.24' }], + selectedAddress: '192.168.1.24' + }) + + expect(screen.getByRole('combobox').textContent).toContain('192.168.1.24 (en0)') + }) +}) diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index bd21eed05fb..99b970b4e96 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -55,6 +55,18 @@ export function NetworkInterfacePicker({ })), [customAddresses] ) + // Why: a host whose only interfaces are container bridges advertises no address by default, but the + // bridges are still listed here — "No interfaces found" would contradict the options right below it. + const placeholder = + options.length > 0 || customOptions.length > 0 + ? translate( + 'auto.components.mobile.NetworkInterfacePicker.no-address-selected', + 'No address selected' + ) + : translate( + 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', + 'No interfaces found' + ) return ( { // Why: regression for the manual-address branch the PR adds — a @@ -38,4 +39,18 @@ describe('selectRefreshedNetworkAddress', () => { it('clears the selection when no interfaces are available', () => { expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() }) + + it('skips a container bridge in favor of a reachable address', () => { + expect(selectRefreshedNetworkAddress(undefined, [BRIDGE, LAN])).toBe(LAN.address) + }) + + // Why: matches main's default — advertising nothing beats advertising docker0, and Relay + // pairs without a direct address. A divergence here would show an address the QR never carried. + it('selects no address when every interface is a container bridge', () => { + expect(selectRefreshedNetworkAddress(undefined, [BRIDGE])).toBeUndefined() + }) + + it('keeps a bridge the user picked explicitly', () => { + expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN])).toBe(BRIDGE.address) + }) }) diff --git a/src/renderer/src/components/settings/mobile-network-interface-selection.ts b/src/renderer/src/components/settings/mobile-network-interface-selection.ts index 2edd817b68f..4b9e4782740 100644 --- a/src/renderer/src/components/settings/mobile-network-interface-selection.ts +++ b/src/renderer/src/components/settings/mobile-network-interface-selection.ts @@ -1,4 +1,4 @@ -import { isTailnetIPv4Address } from '../../../../shared/tailnet-address' +import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection' export type MobileNetworkInterface = { name: string @@ -25,8 +25,7 @@ export function selectRefreshedNetworkAddress( ) { return currentAddress } - return ( - interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? - interfaces[0]!.address - ) + // Why: shared with main so the picker never shows a default the QR didn't advertise. Undefined on + // a bridge-only host: Relay pairs without a direct address rather than offering an unreachable one. + return selectAutoAdvertisedPairingAddress(interfaces) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 467ca68d5aa..962bf2dbec1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12216,6 +12216,7 @@ "confirmationError": "This address could not produce a scannable pairing code. Check the address and try again." }, "NetworkInterfacePicker": { + "no-address-selected": "No address selected", "trigger-label": "Network address to advertise", "custom-option": "{{address}} (custom)", "add-custom": "Add custom address…", diff --git a/src/shared/pairing-address-auto-selection.ts b/src/shared/pairing-address-auto-selection.ts new file mode 100644 index 00000000000..b263b670466 --- /dev/null +++ b/src/shared/pairing-address-auto-selection.ts @@ -0,0 +1,32 @@ +import { isTailnetIPv4Address } from './tailnet-address' + +export type PairingNetworkInterface = { + name: string + address: string +} + +// Why: container/VM bridges are host-local — a phone can never reach docker0 or +// vmnet8 — but they enumerate as ordinary non-internal IPv4, so advertising one +// makes the direct path silently lose the pairing race and every session relay. +// Keyed on interface name, not subnet: Docker's 172.16/12 pool overlaps real +// corporate LANs, so an address test would demote genuine addresses. +const VIRTUAL_BRIDGE_INTERFACE_PATTERN = + /^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|^vEthernet |VMware Network Adapter|VirtualBox Host-Only/i + +export function isVirtualBridgeInterface(name: string): boolean { + return VIRTUAL_BRIDGE_INTERFACE_PATTERN.test(name) +} + +// Why: main mints the QR from this and the renderer's picker shows its result, so both sides must +// agree — a divergence would display one address while the QR advertises another. Bridges stay +// pickable for an explicit choice but are never chosen here; `undefined` means "advertise no direct +// address", which Relay tolerates (it carries its own invite) and the LAN-only path refuses. +export function selectAutoAdvertisedPairingAddress( + interfaces: readonly PairingNetworkInterface[] +): string | undefined { + const advertisable = interfaces.filter((iface) => !isVirtualBridgeInterface(iface.name)) + return ( + advertisable.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? + advertisable[0]?.address + ) +} From 4b2603420e55ef647c02d9c3360bcd15f856bad8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:07:55 -0700 Subject: [PATCH 69/88] fix(terminal): protect local ConPTY Ctrl+Enter without breaking TUIs (#12462) * fix(terminal): gate Ctrl+Enter CSI-u on a negotiated kitty pane Ctrl+Enter emitted \x1b[13;5u unconditionally, so a pane that never negotiated the kitty keyboard protocol (local Windows ConPTY, plain shell) printed the escape verbatim into the prompt. Mirror the Shift+Enter guard and fall back to the legacy CR every emulator sends for this chord. Keeps the intercept, so IME commit ordering and the single-send dedupe still apply. Fixes #12329 Co-authored-by: Orca * test(e2e): negotiate kitty via PTY output in the Ctrl+Enter spec The Ctrl+Enter gate reads the PTY-output kitty tracker, which enableKittyKeyboardReporting never feeds (it writes straight into xterm's parser), so the spec pressed the chord on a pane the policy still saw as un-negotiated and got the CR fallback. Negotiate from the application side like the neighbouring Shift+Enter spec, and reset the flags afterwards for the serial suite. Co-authored-by: Orca * fix(terminal): preserve trusted Ctrl+Enter routing * fix(terminal): scope IME redispatch ownership * fix(terminal): reject conflicting Ctrl+Enter evidence --------- Co-authored-by: Orca Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../ipc/dashboard-payload-validation.test.ts | 3 + src/main/ipc/dashboard-payload-validation.ts | 1 + .../AgentTerminalDialog.test.tsx | 1 + .../preview-terminal-options.test.ts | 2 + .../preview-terminal-shortcuts.test.ts | 41 ++++++++++++ .../preview-terminal-shortcuts.ts | 3 +- .../dashboard-card-terminal-input.test.ts | 33 ++++++++++ .../dashboard-card-terminal-input.ts | 2 + .../terminal-pane/keyboard-handlers.test.ts | 20 ++++++ .../terminal-pane/keyboard-handlers.ts | 65 ++++++++++++++---- .../terminal-pane/terminal-ctrl-enter.test.ts | 66 +++++++++++++++++++ .../terminal-pane/terminal-ctrl-enter.ts | 31 +++++++++ .../terminal-ime-deferred-newline.test.ts | 31 ++++++++- .../terminal-ime-deferred-newline.ts | 56 ++++++++++++---- .../terminal-shortcut-policy.test.ts | 55 +++++++++++----- .../terminal-pane/terminal-shortcut-policy.ts | 18 +++-- src/shared/dashboard-snapshot.ts | 2 + src/shared/tui-agent-config.ts | 8 ++- ...an-ime-terminal-shift-enter-commit.spec.ts | 32 +++++---- tests/e2e/terminal-shortcuts.spec.ts | 22 ++++++- 20 files changed, 429 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index f377ad3c9de..958fff9e8a3 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -279,6 +279,7 @@ describe('dashboard payload validation', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } expect( @@ -292,6 +293,7 @@ describe('dashboard payload validation', () => { { ...terminalInput, localWindowsConpty: 'true' }, { ...terminalInput, osRelease: 'x'.repeat(1_025) }, { ...terminalInput, windowsShiftEnterEncoding: 'enter' }, + { ...terminalInput, ctrlEnterCsiU: 'true' }, { ...terminalInput, kittyKeyboardAdvertised: 1 } ]) { expect( @@ -314,6 +316,7 @@ describe('dashboard payload validation', () => { hostPlatform: 'plan9', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } } diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index f1cc0b66b7c..f819392dae1 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -307,6 +307,7 @@ function isDashboardTerminalInput(value: unknown): boolean { isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) && typeof input.windowsShiftEnterEncoding === 'string' && WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) && + typeof input.ctrlEnterCsiU === 'boolean' && typeof input.kittyKeyboardAdvertised === 'boolean' ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index e788378a6db..7022d7e5731 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -36,6 +36,7 @@ const TERMINAL_INPUT: DashboardCardTerminalInput = { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts index 790334ace6e..e31e2279c9e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.test.ts @@ -61,6 +61,7 @@ describe('buildPreviewTerminalOptions', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -77,6 +78,7 @@ describe('buildPreviewTerminalOptions', () => { localWindowsConpty: true, osRelease: '10.0.22631', windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts index b76c7c14699..4846f2ee153 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.test.ts @@ -11,6 +11,7 @@ const LOCAL_MAC: DashboardCardTerminalInput = { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } @@ -73,6 +74,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: true, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: false } }) @@ -94,6 +96,7 @@ describe('resolvePreviewShortcutAction', () => { hostPlatform: 'win32', localWindowsConpty: false, windowsShiftEnterEncoding: 'csi-u', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true } }) @@ -105,6 +108,44 @@ describe('resolvePreviewShortcutAction', () => { ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + it('protects local ConPTY shells while preserving trusted Ctrl+Enter consumers', () => { + const conpty = contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, + kittyKeyboardAdvertised: false + } + }) + expect( + resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), { + ...conpty, + kittyKeyboardActive: () => true + }) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + expect(resolvePreviewShortcutAction(keydown({ key: 'Enter', ctrlKey: true }), conpty)).toEqual({ + type: 'sendInput', + data: '\r' + }) + expect( + resolvePreviewShortcutAction( + keydown({ key: 'Enter', ctrlKey: true }), + contextFor({ + clientPlatform: 'win32', + terminalInput: { + hostPlatform: 'win32', + localWindowsConpty: true, + windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: true, + kittyKeyboardAdvertised: false + } + }) + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) + it('reports pane-scoped chords so the caller can swallow them', () => { expect( resolvePreviewShortcutAction(keydown({ key: 'd', code: 'KeyD', metaKey: true }), contextFor()) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts index e235c8d4992..1059c6b8b52 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-shortcuts.ts @@ -53,6 +53,7 @@ export function resolvePreviewShortcutAction( () => hostPlatform === 'win32', // Why: without it a terminal-first user's remapped tab.close chord — Ctrl+W // is a shell word-kill — reaches the shell in the pane but is swallowed here. - normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy) + normalizeTerminalShortcutPolicy(context.terminalShortcutPolicy), + () => context.terminalInput?.ctrlEnterCsiU === true ) } diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts index d7aad2ce5b4..77ac13c8b15 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.test.ts @@ -42,6 +42,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) @@ -62,6 +63,37 @@ describe('resolveDashboardCardTerminalInput', () => { expect(profile.kittyKeyboardAdvertised).toBe(true) }) + it('relays trusted Ctrl+Enter authority without coupling it to Shift+Enter', () => { + const droid = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'droid', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(droid.ctrlEnterCsiU).toBe(true) + + const pi = resolveDashboardCardTerminalInput( + stateWith({ + paneForegroundAgentByPaneKey: { + [WINDOWS_ARGS.paneKey]: { + agent: 'pi', + routingTrusted: true, + shellForeground: false + } + } + }), + WINDOWS_ARGS + ) + expect(pi.windowsShiftEnterEncoding).toBe('csi-u') + expect(pi.ctrlEnterCsiU).toBe(false) + }) + // Why: the pty runs Linux inside WSL, so byte protocols must follow it and // not the Windows client — the pane resolves this from its own session cwd. it('treats a WSL shell override as a non-ConPTY Linux-hosted pty', () => { @@ -125,6 +157,7 @@ describe('resolveDashboardCardTerminalInput', () => { hostPlatform: 'darwin', localWindowsConpty: false, windowsShiftEnterEncoding: 'alt-enter', + ctrlEnterCsiU: false, kittyKeyboardAdvertised: true }) }) diff --git a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts index ab7fc611c75..b1b0f16c3ee 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts @@ -9,6 +9,7 @@ import { shouldDisableKittyKeyboardForTerminal } from '@/lib/pane-manager/termin import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/terminal-input-host-platform' import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' /** Store slices the pane's own input helpers read. */ @@ -122,6 +123,7 @@ export function resolveDashboardCardTerminalInput( localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext), ...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }), windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey), + ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey), kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({ ...windowsPtyContext, // Why: launch identity is the only agent signal the board holds; it opts diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts index e2d0dfca565..0e5e3d33ff8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts @@ -110,6 +110,26 @@ describe('resolveTerminalKeyboardShortcutAction', () => { ) ).toEqual({ type: 'sendInput', data: '\x1b\r' }) }) + + it('forwards trusted Ctrl+Enter authority to the shared policy', () => { + expect( + resolveTerminalKeyboardShortcutAction( + makeKeyEvent({ key: 'Enter', ctrlKey: true }), + false, + 'false', + 0, + true, + undefined, + () => true, + () => false, + undefined, + () => 'alt-enter', + () => true, + 'orca-first', + () => true + ) + ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + }) }) describe('runTerminalSearchNavigation', () => { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6519c5f8655..6580bbb81b8 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -46,6 +46,7 @@ import { copyTerminalSelection } from './terminal-selection-copy' import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform' import { markTerminalFollowOutput, @@ -65,7 +66,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode: Parameters[8], getWindowsShiftEnterEncoding: Parameters[9], isWindowsTerminalHost: NonNullable[10]>, - terminalShortcutPolicy: Parameters[11] = 'orca-first' + terminalShortcutPolicy: Parameters[11] = 'orca-first', + hasCtrlEnterCsiUAuthority?: Parameters[12] ): ReturnType { // Why: keep the host callback required at the production boundary so a // caller cannot silently fall back to client-OS byte routing. @@ -81,7 +83,8 @@ export function resolveTerminalKeyboardShortcutAction( layoutBaseCharacterForCode, getWindowsShiftEnterEncoding, isWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasCtrlEnterCsiUAuthority ) } @@ -281,6 +284,7 @@ export function useTerminalKeyboardShortcuts({ // location from its own keydown event and clear it on keyup. let optionKeyLocation = 0 const heldImeEnterModifiers = new Set<'shift' | 'ctrl'>() + const terminalImeEnterModifierKeydowns = new Set<'shift' | 'ctrl'>() const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker() const deferredNewlineSender = createTerminalImeDeferredNewlineSender() const modifiedEnterChordOwner = createTerminalImeModifiedEnterChordOwner() @@ -334,12 +338,14 @@ export function useTerminalKeyboardShortcuts({ (!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope)) && !isEditableTarget(e.target) ) { - heldImeEnterModifiers.add(e.key === 'Shift' ? 'shift' : 'ctrl') + const kind = e.key === 'Shift' ? 'shift' : 'ctrl' + heldImeEnterModifiers.add(kind) + terminalImeEnterModifierKeydowns.add(kind) } } } - // Why: foreground proof and Ctrl+Arrow translation are local ConPTY-only; + // Why: modified-Enter trust and Ctrl+Arrow translation are local ConPTY-only; // resolve lazily so session/runtime lookups stay off ordinary keystrokes. const isLocalWindowsConptyPane = (): boolean => { const manager = managerRef.current @@ -407,6 +413,22 @@ export function useTerminalKeyboardShortcuts({ return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 } + const hasActivePaneCtrlEnterCsiUAuthority = (): boolean => { + const manager = managerRef.current + const activePane = manager?.getActivePane() ?? manager?.getPanes()[0] + if (!activePane) { + return false + } + const state = useAppStore.getState() + return hasCtrlEnterCsiUAuthorityForPane( + state, + makePaneKey(tabId, activePane.leafId), + isLocalWindowsConptyPane() + ? state.runtimePaneTitlesByTabId[tabId]?.[activePane.id] + : undefined + ) + } + const resolveShortcutEvent = ( event: Parameters[0] ): ReturnType => @@ -422,7 +444,8 @@ export function useTerminalKeyboardShortcuts({ getLayoutBaseCharacterForCode, getActivePaneWindowsShiftEnterEncoding, isActivePaneWindowsTerminalHost, - terminalShortcutPolicy + terminalShortcutPolicy, + hasActivePaneCtrlEnterCsiUAuthority ) const createCapturedInputSender = ( @@ -492,7 +515,8 @@ export function useTerminalKeyboardShortcuts({ e.key === 'Enter' && e.keyCode === 13 && !e.isComposing && - ((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || + (modifiedEnterChordOwner.ownsRedispatchedEnter() || + (modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) || deferredNewlineSender.absorbRedispatchedEnter(e)) ) { // Chromium can drop the modifier when re-dispatching the committing Enter. @@ -592,7 +616,13 @@ export function useTerminalKeyboardShortcuts({ if ((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) { if (isWindows) { const chord = getModifiedEnterChord(e) - if (chord && !modifiedEnterChordOwner.claim(chord)) { + const claimedChord = chord + ? { + ...chord, + terminalModifierKeyDownObserved: terminalImeEnterModifierKeydowns.has(chord.kind) + } + : null + if (claimedChord && !modifiedEnterChordOwner.claim(claimedChord)) { return } } @@ -804,6 +834,7 @@ export function useTerminalKeyboardShortcuts({ if (releasedImeEnterModifier) { const kind = releasedImeEnterModifier heldImeEnterModifiers.delete(kind) + terminalImeEnterModifierKeydowns.delete(kind) modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp }) } if (e.key !== 'Enter') { @@ -819,20 +850,26 @@ export function useTerminalKeyboardShortcuts({ observedEnterKeydownTimeStamps.delete(e.code) } } - const modifiedEnterKind = getImeEnterModifier(e) - if (isWindows && modifiedEnterKind && isTerminalImeEnterKeyUp(e)) { - const chord = { kind: modifiedEnterKind, code: e.code, timeStamp: e.timeStamp } - if (modifiedEnterChordOwner.absorb(chord)) { - modifiedEnterChordOwner.release(chord) + if (isWindows && isTerminalImeEnterKeyUp(e)) { + const originatingChord = modifiedEnterChordOwner.releaseForEnterKeyUp() + if (originatingChord) { e.preventDefault() e.stopImmediatePropagation() - deferredNewlineSender.releaseRedispatchedEnter(e) + const modifierStillMatches = getImeEnterModifier(e) === originatingChord.kind + deferredNewlineSender.releaseRedispatchedEnter( + e, + modifierStillMatches || originatingChord.terminalModifierKeyDownObserved + ? originatingChord + : undefined + ) return } + const modifiedEnterKind = getImeEnterModifier(e) const manager = managerRef.current const keyboardScope = keyboardScopeRef.current if ( + modifiedEnterKind && // An observed keydown makes this modifier state rollover, not a swallowed chord. !enterKeydownWasObserved && manager && @@ -864,6 +901,7 @@ export function useTerminalKeyboardShortcuts({ } } + const modifiedEnterKind = getImeEnterModifier(e) if (modifiedEnterKind) { modifiedEnterChordOwner.release({ kind: modifiedEnterKind, @@ -898,6 +936,7 @@ export function useTerminalKeyboardShortcuts({ const onNativeOnlyBlur = (): void => { nativeOnlyShortcutTracker.clear() heldImeEnterModifiers.clear() + terminalImeEnterModifierKeydowns.clear() modifiedEnterChordOwner.clear() deferredNewlineSender.clearRedispatchedEnters() observedEnterKeydownTimeStamps.clear() diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts new file mode 100644 index 00000000000..27e0cce41bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' + +const PANE_KEY = 'tab:pane' + +describe('hasCtrlEnterCsiUAuthorityForPane', () => { + it('authorizes only trusted Ctrl+Enter CSI-u consumers', () => { + for (const agent of ['droid', 'grok'] as const) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent, routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(true) + } + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'pi', routingTrusted: true, shellForeground: false } + } + }, + PANE_KEY + ) + ).toBe(false) + }) + + it('uses strict titles only through unrevoked trust gaps', () => { + const state = { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'droid' as const, shellForeground: false } + } + } + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, '⠋ Droid')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, 'C:\\work\\grok-project')).toBe(false) + expect( + hasCtrlEnterCsiUAuthorityForPane( + { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'pi', shellForeground: false } + } + }, + PANE_KEY, + 'Droid' + ) + ).toBe(false) + + for (const foreground of [ + { agent: 'grok' as const, routingRevoked: true, shellForeground: false }, + { agent: null, shellForeground: true }, + { agent: 'droid' as const, routingTrusted: true, shellForeground: true } + ]) { + expect( + hasCtrlEnterCsiUAuthorityForPane( + { paneForegroundAgentByPaneKey: { [PANE_KEY]: foreground } }, + PANE_KEY, + 'Grok' + ) + ).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts new file mode 100644 index 00000000000..457a1f51beb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts @@ -0,0 +1,31 @@ +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { resolveCommittedTitleAgentType } from '../../lib/pane-agent-evidence' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +type CtrlEnterPaneState = { + paneForegroundAgentByPaneKey: Record +} + +function agentAcceptsCtrlEnterCsiU(agent: PaneForegroundAgentEntry['agent']): boolean { + return agent !== null && TUI_AGENT_CONFIG[agent].ctrlEnterEncoding === 'csi-u' +} + +/** Resolves pane-scoped authority for query-only CSI-u consumers such as Droid and Grok. */ +export function hasCtrlEnterCsiUAuthorityForPane( + state: CtrlEnterPaneState, + paneKey: string, + terminalTitle?: string +): boolean { + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + if (foreground?.shellForeground === true || foreground?.routingRevoked === true) { + return false + } + if (foreground?.routingTrusted === true) { + return agentAcceptsCtrlEnterCsiU(foreground.agent) + } + const titleAgent = terminalTitle ? resolveCommittedTitleAgentType(terminalTitle) : null + if (foreground?.agent != null && foreground.agent !== titleAgent) { + return false + } + return agentAcceptsCtrlEnterCsiU(titleAgent) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts index 12f917c8018..d1ef2b882eb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.test.ts @@ -180,6 +180,17 @@ describe('createTerminalImeDeferredNewlineSender', () => { expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true) }) + it('moves a credit to a balancing keyup with the redispatch timestamp', () => { + const el = document.createElement('div') + const sender = createSender() + + sender.defer(enter(10), el, vi.fn()) + sender.releaseRedispatchedEnter(enter(20), enter(10)) + + expect(sender.absorbRedispatchedEnter(enter(20))).toBe(true) + expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false) + }) + it('releases an unused credit on a later physical keyup', () => { const el = document.createElement('div') const sender = createSender() @@ -289,6 +300,22 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { expect(defer).toHaveBeenCalledTimes(1) expect(owner.absorb(chord('shift', 5037.9, 'Enter'))).toBe(true) + expect(owner.ownsRedispatchedEnter()).toBe(false) + }) + + it('owns a modifier-lost redispatch only after a terminal modifier keydown', () => { + const owner = createTerminalImeModifiedEnterChordOwner() + + expect(owner.claim({ ...chord('shift', 10), terminalModifierKeyDownObserved: true })).toBe(true) + expect(owner.ownsRedispatchedEnter()).toBe(true) + }) + + it('releases a claimed chord when the balancing Enter keyup loses its modifier', () => { + const owner = createTerminalImeModifiedEnterChordOwner() + + expect(owner.claim(chord('shift', 10))).toBe(true) + expect(owner.releaseForEnterKeyUp()).toEqual(chord('shift', 10)) + expect(owner.ownsRedispatchedEnter()).toBe(false) }) it('does not merge different modified Enter kinds into one chord', () => { @@ -303,7 +330,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('ctrl', 10))).toBe(true) - owner.release(chord('ctrl', 30, 'Enter')) + expect(owner.release(chord('ctrl', 30, 'Enter'))).toEqual(chord('ctrl', 10)) expect(owner.absorb(chord('ctrl', 40, 'Enter'))).toBe(false) expect(owner.claim(chord('ctrl', 40, 'Enter'))).toBe(true) @@ -313,7 +340,7 @@ describe('createTerminalImeModifiedEnterChordOwner', () => { const owner = createTerminalImeModifiedEnterChordOwner() expect(owner.claim(chord('shift', 10))).toBe(true) - owner.release(chord('ctrl', 20)) + expect(owner.release(chord('ctrl', 20))).toBeNull() expect(owner.absorb(chord('shift', 30))).toBe(true) owner.clear() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts index 5c79bd6419a..2fd141fe3ce 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-deferred-newline.ts @@ -52,7 +52,10 @@ export type TerminalImeDeferredNewlineSender = { send: () => void ) => void absorbRedispatchedEnter: (enter: TerminalImeEnterIdentity) => boolean - releaseRedispatchedEnter: (enter: TerminalImeEnterIdentity) => void + releaseRedispatchedEnter: ( + enter: TerminalImeEnterIdentity, + originatingEnter?: TerminalImeEnterIdentity + ) => void clearRedispatchedEnters: () => void } @@ -62,34 +65,46 @@ export type TerminalImeModifiedEnterKind = 'shift' | 'ctrl' export type TerminalImeModifiedEnterChord = TerminalImeEnterIdentity & { kind: TerminalImeModifiedEnterKind + terminalModifierKeyDownObserved?: boolean } export type TerminalImeModifiedEnterChordOwner = { claim: (chord: TerminalImeModifiedEnterChord) => boolean absorb: (chord: TerminalImeModifiedEnterChord) => boolean - release: (chord: TerminalImeModifiedEnterChord) => void + ownsRedispatchedEnter: () => boolean + release: (chord: TerminalImeModifiedEnterChord) => TerminalImeModifiedEnterChord | null + releaseForEnterKeyUp: () => TerminalImeModifiedEnterChord | null clear: () => void } export function createTerminalImeModifiedEnterChordOwner(): TerminalImeModifiedEnterChordOwner { - let activeKind: TerminalImeModifiedEnterKind | null = null + let activeChord: TerminalImeModifiedEnterChord | null = null return { - claim: ({ kind }) => { - if (activeKind !== null) { + claim: (chord) => { + if (activeChord !== null) { return false } - activeKind = kind + activeChord = chord return true }, - absorb: ({ kind }) => activeKind === kind, + absorb: ({ kind }) => activeChord?.kind === kind, + ownsRedispatchedEnter: () => activeChord?.terminalModifierKeyDownObserved === true, release: ({ kind }) => { - if (activeKind === kind) { - activeKind = null + if (activeChord?.kind === kind) { + const releasedChord = activeChord + activeChord = null + return releasedChord } + return null + }, + releaseForEnterKeyUp: () => { + const releasedChord = activeChord + activeChord = null + return releasedChord }, clear: () => { - activeKind = null + activeChord = null } } } @@ -184,7 +199,26 @@ export function createTerminalImeDeferredNewlineSender(): TerminalImeDeferredNew cleanUpIfSettled(enter, state) return true }, - releaseRedispatchedEnter: (enter) => { + releaseRedispatchedEnter: (enter, originatingEnter) => { + if (originatingEnter) { + const originatingState = statesByEnterCode + .get(originatingEnter.code) + ?.get(originatingEnter.timeStamp) + if (originatingState && originatingState.absorbCredits > 0) { + originatingState.absorbCredits -= 1 + cleanUpIfSettled(originatingEnter, originatingState) + + const statesByTimeStamp = statesByEnterCode.get(enter.code) ?? new Map() + const state = statesByTimeStamp.get(enter.timeStamp) ?? { + inFlightSends: 0, + absorbCredits: 0 + } + state.absorbCredits += 1 + statesByTimeStamp.set(enter.timeStamp, state) + statesByEnterCode.set(enter.code, statesByTimeStamp) + return + } + } if (statesByEnterCode.get(enter.code)?.has(enter.timeStamp)) { // Chromium's balancing Process-key keyup is copied from the same native event. return diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 2736d5e0797..eec4a3cdd22 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -270,27 +270,50 @@ describe('resolveTerminalShortcutAction', () => { expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2) }) - it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => { - // Why: xterm.js collapses Ctrl+Enter to a bare CR; intercept upstream and - // emit the kitty sequence (modifier code 5 = Ctrl) so probing TUIs receive - // the distinct chord on every platform. - expect( - resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), true) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) - expect( - resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) - // Windows uses the same kitty sequence for now: no TUI is known to treat the - // CSI-u Ctrl+Enter form as inert (cf. the Shift+Enter Codex-on-PowerShell case). - expect( - resolveTerminalShortcutAction( + it('protects local ConPTY shells without regressing query-only Ctrl+Enter consumers', () => { + const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const) + const isLocalWindowsConptyPane = vi.fn(() => true) + const isKittyKeyboardActivePane = vi.fn(() => false) + const hasCtrlEnterCsiUAuthority = vi.fn(() => false) + const csiU = { type: 'sendInput', data: '\x1b[13;5u' } + const legacyCr = { type: 'sendInput', data: '\r' } + const resolveCtrlEnter = ( + localConpty: boolean, + kittyActive: boolean, + trustedConsumer: boolean + ) => { + isLocalWindowsConptyPane.mockReturnValue(localConpty) + isKittyKeyboardActivePane.mockReturnValue(kittyActive) + hasCtrlEnterCsiUAuthority.mockReturnValue(trustedConsumer) + return resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false, 'false', 0, - true + true, + undefined, + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, + undefined, + getWindowsShiftEnterEncoding, + () => true, + 'orca-first', + hasCtrlEnterCsiUAuthority ) - ).toEqual({ type: 'sendInput', data: '\x1b[13;5u' }) + } + + expect(resolveCtrlEnter(true, false, false)).toEqual(legacyCr) + expect(resolveCtrlEnter(true, true, false)).toEqual(csiU) + expect(resolveCtrlEnter(true, false, true)).toEqual(csiU) + hasCtrlEnterCsiUAuthority.mockClear() + expect(resolveCtrlEnter(false, false, false)).toEqual(csiU) + expect(hasCtrlEnterCsiUAuthority).not.toHaveBeenCalled() + expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() + + // Missing pane-host context preserves the established Droid/Grok chord. + expect( + resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', ctrlKey: true }), false) + ).toEqual(csiU) // Modifier combos that are NOT plain Ctrl+Enter must keep falling through. expect( diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 57cc3eb4de6..6ab00a13806 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -96,7 +96,7 @@ export function resolveTerminalShortcutAction( optionKeyLocation: number = 0, isWindows: boolean = false, keybindings?: KeybindingOverrides, - // Why: lazy so execution-host lookup (local native Windows ConPTY) runs only on Ctrl+Arrow, not every keystroke. + // Why: lazy so local ConPTY lookup runs only for Ctrl+Arrow and Ctrl+Enter. isLocalWindowsConptyPane?: () => boolean, // Why: gates Option-as-Alt compensation on the app's own kitty-protocol (CSI > u) opt-in, so shells keep composition. isKittyKeyboardActivePane?: () => boolean, @@ -107,7 +107,9 @@ export function resolveTerminalShortcutAction( // Why: keybindings follow the client OS, but byte protocols follow the PTY host — they differ for macOS clients on Windows runtimes. isWindowsTerminalHost: () => boolean = () => isWindows, // Why: gates the tab.close pane-close alias — under terminal-first a remapped tab.close yields to the shell (terminal.closePane, scope terminal, still closes). - terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first' + terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first', + // Why: query-only Droid/Grok consumers need CSI-u even when the live kitty flags remain inactive. + hasCtrlEnterCsiUAuthority?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -195,8 +197,16 @@ export function resolveTerminalShortcutAction( !event.shiftKey && event.key === 'Enter' ) { - // Why: xterm.js collapses Ctrl+Enter to a bare CR, so forward kitty CSI-u (modifier 5 = Ctrl) so the chord reaches TUIs; no Windows fallback yet (#2418). - return { type: 'sendInput', data: '\x1b[13;5u' } + const localWindowsConpty = isLocalWindowsConptyPane?.() === true + // Why: preserve query-only TUI chords elsewhere; local ConPTY shells require negotiation or trusted consumer evidence (#12329). + const canSendCsiU = + !localWindowsConpty || + isKittyKeyboardActivePane?.() === true || + hasCtrlEnterCsiUAuthority?.() === true + return { + type: 'sendInput', + data: canSendCsiU ? '\x1b[13;5u' : '\r' + } } if ( diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 4f563886774..9367393118e 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -154,6 +154,8 @@ export type DashboardCardTerminalInput = { osRelease?: string /** Shift+Enter encoding resolved from this pane's agent evidence. */ windowsShiftEnterEncoding: 'alt-enter' | 'csi-u' + /** Trusted query-only consumer accepts Ctrl+Enter CSI-u without active flags. */ + ctrlEnterCsiU: boolean /** False withholds the kitty (CSI-u) advertisement, as ConPTY panes do. */ kittyKeyboardAdvertised: boolean } diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index ffaa334fda9..e3b855a4c9e 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -41,6 +41,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ windowsShiftEnterEncoding?: 'csi-u' + /** Ctrl+Enter encoding for agents that consume CSI-u without active kitty flags. */ + ctrlEnterEncoding?: 'csi-u' } export const TUI_AGENT_CONFIG: Record = { @@ -239,7 +241,8 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'droid', promptInjectionMode: 'argv', // Why: Droid decodes CSI-u on Windows; the legacy Esc+CR fallback reads as Enter and submits instead of newline. - windowsShiftEnterEncoding: 'csi-u' + windowsShiftEnterEncoding: 'csi-u', + ctrlEnterEncoding: 'csi-u' }, kimi: { detectCmd: 'kimi', @@ -298,7 +301,8 @@ export const TUI_AGENT_CONFIG: Record = { // Why: argv (grok takes a positional prompt) so multi-line/special-char text isn't mangled as raw PTY keystrokes. promptInjectionMode: 'argv', // Why: separator so prompts like `help`/`--version` aren't parsed as Grok CLI syntax. - argvPromptSeparator: '--' + argvPromptSeparator: '--', + ctrlEnterEncoding: 'csi-u' }, devin: { detectCmd: 'devin', diff --git a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts index 4c66fea3c5c..208e79a8928 100644 --- a/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts +++ b/tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts @@ -380,23 +380,33 @@ async function assertShiftOutcome(page: Page): Promise { } async function assertCtrlOutcome(page: Page): Promise { + if (process.platform !== 'win32') { + await expect + .poll(() => readReceived(page), { + timeout: 10_000, + message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord' + }) + .toBe('하 하 하\u001b[13;5u') + expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([]) + return + } + await expect .poll(() => readReceived(page), { timeout: 10_000, message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord' }) - .toBe('하 하 하\u001b[13;5u') + .toBe('하 하 하\r') await expect .poll(() => readPromptLine(page), { timeout: 10_000, - message: 'prompt must show the committed syllables followed by exactly one CSI-u chord' + message: 'prompt must be empty — no literal escape bytes may survive the chord' }) - .toBe('하 하 하[13;5u') + .toBe('') await page.waitForTimeout(500) - expect(await readPromptLine(page), 'Ctrl+Enter must produce exactly one CSI-u chord').toBe( - '하 하 하[13;5u' - ) - expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([]) + expect(await readSubmitted(page), 'Ctrl+Enter must produce exactly one newline').toEqual([ + '하 하 하' + ]) } const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ @@ -416,8 +426,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ modifiers: 2, assertOutcome: assertCtrlOutcome, expectedAfterPlainEnter: { - received: '하 하 하\u001b[13;5u\r', - submitted: ['하 하 하\u001b[13;5u'] + received: process.platform === 'win32' ? '하 하 하\r\r' : '하 하 하\u001b[13;5u\r', + submitted: process.platform === 'win32' ? ['하 하 하', ''] : ['하 하 하\u001b[13;5u'] } }, { @@ -455,8 +465,8 @@ const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [ windowsOnly: true, assertOutcome: assertCtrlOutcome, expectedAfterPlainEnter: { - received: '하 하 하\u001b[13;5u\r', - submitted: ['하 하 하\u001b[13;5u'] + received: '하 하 하\r\r', + submitted: ['하 하 하', ''] } } ] diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 5c921376ca5..10114b30cb0 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -526,13 +526,29 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Control+Alt+;', '\x1b;') }) - test('Ctrl+Enter writes the kitty modified-enter chord for terminal TUIs', async ({ + test('Ctrl+Enter protects local ConPTY shells without breaking trusted TUI chords', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) await waitForActivePanePtyId(orcaPage) + if (process.platform === 'win32') { + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\r') + const paneKey = await setActivePaneForegroundAgent(orcaPage, 'droid') + try { + // Droid queries CSI-u without activating live flags; trusted process evidence preserves cue/queue. + await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') + } finally { + await orcaPage.evaluate( + (key) => window.__store?.getState().clearPaneForegroundAgent(key), + paneKey + ) + } + return + } + + // Preserve the established query-only Droid/Grok contract outside local ConPTY. await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') }) @@ -730,8 +746,8 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Alt+ArrowRight', '\x1bf') // Ctrl+←/→ on non-mac → readline backward-word / forward-word (\eb / \ef). - // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces. - if (!isMac) { + // macOS reserves Ctrl+Arrow; Windows ConPTY leaves it to PSReadLine. + if (!isMac && process.platform !== 'win32') { await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowLeft', '\x1bb') await pressAndExpectWrite(orcaPage, electronApp, 'Control+ArrowRight', '\x1bf') } From 87d768058c27ab9db395da1fae301ec4facadcf1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:18:41 -0700 Subject: [PATCH 70/88] fix(terminal): recover stale persisted owner bindings (#12976) Co-authored-by: Jinwoo-H --- config/reliability-gates.jsonc | 85 ++++++-- src/main/daemon/daemon-pty-router.test.ts | 14 +- .../daemon-session-owner-resolution.test.ts | 184 +++++++++++++++--- .../daemon/daemon-session-owner-resolution.ts | 6 +- .../degraded-daemon-pty-provider.test.ts | 77 +++++++- .../daemon/degraded-daemon-pty-provider.ts | 4 + 6 files changed, 317 insertions(+), 53 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cfd44cc18..33945cfe8c2 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5319,7 +5319,7 @@ "id": "terminal-provider.daemon-startup-degraded-contract", "title": "Daemon startup reconcile and degraded fallback preserve provider identity", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-provider", "layer": "provider-contract", "surfaces": [ @@ -5327,26 +5327,75 @@ "degraded daemon", "fallback PTY", "provider ownership", - "startup restore" + "startup restore", + "stable-pane reopen" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "local"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["daemon", "local"], + "coverageNotes": "Deterministic provider contracts cover complete, incomplete, conflicting, refused, and identity-invalidated owner inventories; degraded fallback/current/legacy routing; repeated stale-binding classification; and fresh-session reattach without local fallback during unresolved ownership. Main IPC contracts cover exact persisted-binding retirement and one fresh replacement after confirmed absence. Live Linux, Windows, WSL, SSH, paired-runtime, and packaged-upgrade journeys remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6830", "https://github.com/stablyai/orca/pull/6866", - "https://github.com/stablyai/orca/pull/7002" + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/12776" + ], + "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions and reap only true orphans. A persisted binding stays fail-closed while any possible owner is incomplete, conflicting, refusing attach, or identity-invalidated, but complete authoritative absence from every configured provider must retire that exact stale binding and let retry or reopen converge without attaching it through local fallback or duplicating a PTY.", + "oracle": "Hold exact fallback, current-daemon, and legacy-daemon inventories behind controlled promises. No attach may settle or dispatch until every provider answers. Complete zero-candidate inventories must report SessionNotFound; incomplete, duplicate, refused, and identity-invalidated attempts must remain owner-unverified. In degraded mode, repeated stale attempts scan each eligible provider once per attempt and dispatch no attach, then one explicit fresh spawn and exact reattach use only the recorded fresh route. Main IPC must compare-and-swap retire only the matching persisted PTY/incarnation, emit one synthetic exit, and create one different fresh PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/daemon/daemon-session-owner-resolution.test.ts", + "src/main/daemon/daemon-pty-router.test.ts", + "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/daemon-session-owner-resolution.test.ts", + "assertions": [ + "controlled concurrent inventories wait for fallback, current, and legacy authority before classifying exact persisted PTYs absent", + "incomplete inventory, duplicate claims, owner refusal, and identity replacement stay fail-closed with exact list and spawn counts" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router.test.ts", + "assertions": [ + "complete current and legacy daemon inventories prove liveness absence with one list call per adapter", + "one unavailable daemon inventory keeps the same missing PTY liveness unknown" + ] + }, + { + "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts", + "assertions": [ + "repeated complete absence never dispatches the stale id to fallback or either daemon", + "one explicit fresh fallback PTY reattaches by its exact PTY and incarnation without another inventory", + "a mapped fallback-owned PTY reports live without borrowing fallback authority for unknown ids or scanning daemon inventories" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "confirmed absence retires the exact persisted binding once and creates one differently identified fresh PTY", + "unverified ownership retains the binding and creates no fresh PTY" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-06", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/ipc/pty.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 5.44, + "summary": "Four files passed 535 owner-resolution, router, degraded-provider, and IPC lifecycle tests. With only the resolver fix disabled, the unchanged four-file gate failed four stale-absence, identity-retry, and router-liveness assertions; restoring it passed 535 of 535." + } ], - "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions, reap only true orphans, and degraded mode must not route an existing-looking daemon session through local fallback unless the caller explicitly marks a fresh degraded-mode spawn.", - "oracle": "The current provider-contract corpus covers valid current-worktree ids, folder/floating workspace ids, invalid removed-worktree ids, mixed live/orphan dry-run reconciliation, hyphenated worktree ids, malformed ids, daemon sessions discovered after restart, router discovery before existing-session spawn, router fail-closed behavior when legacy ownership cannot be listed or a known session has exited, degraded daemon fallback for fresh sessions, fail-closed behavior for unknown restored ids, benign inspection defaults for unknown ownership, and synthetic exits on daemon restart. Prior-worktree aliases and renamed-worktree startup wiring are promotion gaps, not current proof.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { - "p95Seconds": 20, + "p95Seconds": 45, "scope": "provider contract unit/integration test" }, "flakeHistory": { @@ -5354,22 +5403,22 @@ "evidence": "Focused provider contract tests now run locally; needs soak history before promotion." }, "redGreenEvidence": { - "status": "partial", - "evidence": "Tests assert discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy listing fails or a known session has exited, targeted hasPty discovery caches legacy ownership before later write/resize-style operations, real daemon adapter listProcesses discovery seeds targeted hasPty liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, operations on unknown or exited existing ids fail closed, startup reconcile can dry-run orphan detection without killing live sessions, and process inspection returns benign defaults for unknown ownership. Production startup reconcile wiring remains unproven." + "status": "complete", + "evidence": "The byte-identical owner-resolver blob d35d2795d56e7e32c1ca99af6726fe6b92181881 is present in v1.4.176-rc.0@ddf64199fa, prod-release-1.4.176@8ddf575fe6, and origin/main@cb960408f2. It returns TerminalSessionOwnerUnverifiedError for both controlled complete-empty persisted PTYs and leaves degraded stale bindings unrecoverable. The candidate passes 535 of 535 registered tests. Restoring only the old predicate makes the unchanged four-file gate fail four of 535 tests; restoring the fix passes 535 of 535." }, "performanceBudget": { "required": true, - "evidence": "Reconcile must not add startup-blocking scans beyond the explicit daemon session inventory and must not leak global listing into hot paths." + "evidence": "One unresolved attempt performs one concurrent listProcesses call per eligible provider, concurrent pane attempts coalesce onto that inventory, and no polling, sleep, timer, subprocess, listener, or retry loop is added. Confirmed absence is not cached; repeated explicit attempts remain bounded to one fanout each. Reattaching the newly recorded fresh route adds no inventory or daemon spawn." }, "promotionCriteria": [ "Wire reconcileOnStartup or mark the production wiring gap explicitly.", "Cover priorWorktreeIds so renamed worktrees are not falsely reaped." ], "knownGaps": [ - "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", "Production startup reconcile wiring remains unproven.", "Prior-worktree aliases and renamed-worktree startup reconcile are not covered by the current executable corpus.", - "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests." + "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests.", + "No live Linux, Windows, WSL, SSH, headed/headless paired-runtime, folder-workspace, or mixed-version packaged-upgrade journey was run for stale-owner recovery." ], "demotionRule": "Cannot promote while restored daemon ids or routing operations can silently route to local fallback." }, diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 1930224c2d5..11b6c53dee0 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -526,12 +526,24 @@ describe('DaemonPtyRouter', () => { it('does not report absence while any possible daemon owner is unavailable', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy') - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() }) + it('reports absence after every possible daemon owner returns a complete inventory', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await expect(router.probePtyLiveness('missing-session')).resolves.toBe(false) + expect(current.listProcesses).toHaveBeenCalledOnce() + expect(legacy.listProcesses).toHaveBeenCalledOnce() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('routes an attach to a legacy session created after startup inventory', async () => { const current = createAdapter('current') const legacySessions = ['legacy-at-startup'] diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts index 277c0750a84..55dc6142658 100644 --- a/src/main/daemon/daemon-session-owner-resolution.test.ts +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -21,31 +21,103 @@ function provider( } describe('DaemonSessionOwnerResolver', () => { - it('coalesces concurrent all-provider inventories', async () => { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve + it('coalesces complete multi-provider absence without dispatching an attach', async () => { + let releaseFallback!: (processes: PtyProcessInfo[]) => void + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const fallbackGate = new Promise((resolve) => { + releaseFallback = resolve }) - const firstInventory = vi.fn(async () => { - await gate - return [] + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve }) - const secondInventory = vi.fn(async () => { - await gate - return [] + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve }) - const first = provider(firstInventory) - const second = provider(secondInventory) - const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + const fallbackInventory = vi.fn(() => fallbackGate) + const currentInventory = vi.fn(() => currentGate) + const legacyInventory = vi.fn(() => legacyGate) + const fallback = provider(fallbackInventory) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([fallback, current, legacy], new Map()) - const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')]) - await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce()) - expect(secondInventory).toHaveBeenCalledOnce() - release() + const resolutions = [ + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }), + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-beta', + expectedIncarnationId: 'incarnation-persisted-beta', + attachOnly: true, + cols: 80, + rows: 24 + }) + ] + let settled = 0 + for (const resolution of resolutions) { + void resolution.then( + () => { + settled += 1 + }, + () => { + settled += 1 + } + ) + } - await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]) - expect(firstInventory).toHaveBeenCalledOnce() - expect(secondInventory).toHaveBeenCalledOnce() + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + releaseFallback([]) + releaseCurrent([]) + for (let iteration = 0; iteration < 10; iteration += 1) { + await Promise.resolve() + } + expect(settled).toBe(0) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + releaseLegacy([]) + + const results = await Promise.allSettled(resolutions) + expect(results).toEqual([ + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }), + expect.objectContaining({ + status: 'rejected', + reason: expect.any(SessionNotFoundError) + }) + ]) + expect(settled).toBe(2) + expect(fallbackInventory).toHaveBeenCalledOnce() + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'pty-persisted-alpha', + expectedIncarnationId: 'incarnation-persisted-alpha', + attachOnly: true, + cols: 80, + rows: 24 + }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(fallbackInventory).toHaveBeenCalledTimes(2) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() }) it('bounds restore inventory requests across many panes and daemon generations', async () => { @@ -312,6 +384,10 @@ describe('DaemonSessionOwnerResolver', () => { const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' }) + expect(first.listProcesses).toHaveBeenCalledOnce() + expect(second.listProcesses).toHaveBeenCalledOnce() + expect(first.spawn).not.toHaveBeenCalled() + expect(second.spawn).not.toHaveBeenCalled() }) it('reports confirmed absence from a sole owner', async () => { @@ -337,19 +413,19 @@ describe('DaemonSessionOwnerResolver', () => { }) it('preserves an unresolved owner when any provider inventory fails', async () => { - const resolver = new DaemonSessionOwnerResolver( - [ - provider(async () => []), - provider(async () => { - throw new Error('offline') - }) - ], - new Map() - ) + const reachable = provider(async () => []) + const unreachable = provider(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver([reachable, unreachable], new Map()) await expect( resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(reachable.listProcesses).toHaveBeenCalledOnce() + expect(unreachable.listProcesses).toHaveBeenCalledOnce() + expect(reachable.spawn).not.toHaveBeenCalled() + expect(unreachable.spawn).not.toHaveBeenCalled() }) it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => { @@ -372,6 +448,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).not.toHaveBeenCalled() }) it('does not turn a post-inventory owner refusal into aggregate absence', async () => { @@ -389,6 +467,8 @@ describe('DaemonSessionOwnerResolver', () => { await expect( resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(candidate.listProcesses).toHaveBeenCalledOnce() + expect(candidate.spawn).toHaveBeenCalledOnce() }) it('pre-routes every uniquely inventoried session for serialized restores', async () => { @@ -429,6 +509,52 @@ describe('DaemonSessionOwnerResolver', () => { expect(inventory).toHaveBeenCalledTimes(2) }) + it('fails closed across identity replacement then confirms absence on retry', async () => { + let releaseCurrent!: (processes: PtyProcessInfo[]) => void + let releaseLegacy!: (processes: PtyProcessInfo[]) => void + const currentGate = new Promise((resolve) => { + releaseCurrent = resolve + }) + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve + }) + const currentInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(currentGate) + .mockResolvedValueOnce([]) + const legacyInventory = vi + .fn<() => Promise>() + .mockReturnValueOnce(legacyGate) + .mockResolvedValueOnce([]) + const current = provider(currentInventory) + const legacy = provider(legacyInventory) + const resolver = new DaemonSessionOwnerResolver([current, legacy], new Map()) + const attach = { + sessionId: 'pty-persisted-after-identity-change', + expectedIncarnationId: 'incarnation-before-identity-change', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + const staleAttempt = resolver.spawnAttachOnly(attach) + expect(currentInventory).toHaveBeenCalledOnce() + expect(legacyInventory).toHaveBeenCalledOnce() + resolver.invalidateProvider(current) + releaseCurrent([]) + releaseLegacy([]) + + await expect(staleAttempt).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + await expect(resolver.spawnAttachOnly(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + expect(currentInventory).toHaveBeenCalledTimes(2) + expect(legacyInventory).toHaveBeenCalledTimes(2) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('restores a proven route when identity changes during exact reattach', async () => { const routes = new Map() let resolver!: DaemonSessionOwnerResolver diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts index d35d2795d56..1906ebfb35b 100644 --- a/src/main/daemon/daemon-session-owner-resolution.ts +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -223,10 +223,10 @@ export class DaemonSessionOwnerResolver { this.recordRoute(sessionId, provider, process?.incarnationId) return { kind: 'owner', provider } } - if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) { - return { kind: 'unknown' } + if (inventory.complete && providers.size === 0 && this.providers.length > 0) { + return { kind: 'absent' } } - return { kind: 'absent' } + return { kind: 'unknown' } } private inventory(allowCached = true): Promise> { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index af7c30d1c5d..e8a8e4d1214 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,7 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -200,6 +200,66 @@ it('keeps an attach unresolved when a legacy inventory listing fails', async () expect(current.spawn).not.toHaveBeenCalled() }) +it('confirms repeated stale-binding absence without falling back or spawning', async () => { + const current = createDaemonAdapter('daemon-current') + const legacy = createDaemonAdapter('daemon-legacy') + const fallback = createProvider('local-fallback') + vi.mocked(fallback.spawn).mockImplementation(async (opts) => + opts.attachOnly + ? { + id: opts.sessionId!, + incarnationId: 'incarnation-fresh-local', + isReattach: true + } + : { id: 'pty-fresh-local', incarnationId: 'incarnation-fresh-local' } + ) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const attach = { + sessionId: 'pty-persisted-missing', + expectedIncarnationId: 'incarnation-persisted-missing', + attachOnly: true, + cols: 80, + rows: 24 + } as const + + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + await expect(provider.spawn(attach)).rejects.toBeInstanceOf(SessionNotFoundError) + + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + expect(fallback.spawn).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + await expect( + provider.spawn({ + sessionId: fresh.id, + expectedIncarnationId: fresh.incarnationId, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ + id: 'pty-fresh-local', + incarnationId: 'incarnation-fresh-local', + isReattach: true + }) + + expect(fallback.spawn).toHaveBeenCalledTimes(2) + expect(vi.mocked(fallback.spawn).mock.calls[0]?.[0]).not.toHaveProperty('sessionId') + expect(vi.mocked(fallback.spawn).mock.calls[1]?.[0]).toMatchObject({ + sessionId: 'pty-fresh-local', + attachOnly: true + }) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) +}) + it('rejects completion inspection instead of borrowing the fallback provider', async () => { const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('daemon'), @@ -415,7 +475,7 @@ describe('DegradedDaemonPtyProvider', () => { const legacy = createDaemonAdapter('legacy') const fallback = createProvider('fallback', ['unknown-session']) const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) - vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy inventory unavailable')) await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() expect(fallback.probePtyLiveness).not.toHaveBeenCalled() @@ -424,6 +484,19 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) }) + it('reports a routed fallback PTY live before probing daemon absence', async () => { + const current = createDaemonAdapter('current') + const legacy = createDaemonAdapter('legacy') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.probePtyLiveness(fresh.id)).resolves.toBe(true) + expect(fallback.hasPty).toHaveBeenCalledWith(fresh.id) + expect(current.listProcesses).not.toHaveBeenCalled() + expect(legacy.listProcesses).not.toHaveBeenCalled() + }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 6b1c738b316..d1a9e90a2eb 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -95,6 +95,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async probePtyLiveness(id: string): Promise { + const mapped = this.sessionProviders.get(id) + if (mapped && (mapped.hasPty?.(id) ?? true)) { + return true + } return await this.ownerRecovery.probe(id) } From c9485fdded0d5ab50fcbbf81e16aecf12c4ae009 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:18:52 -0700 Subject: [PATCH 71/88] fix(computer): fence macOS HID coordinate clicks (#12981) Co-authored-by: Jinwoo-H --- .../computer-use-modifier-safety.test.mjs | 3 +- .../Sources/OrcaComputerUseMacOS/main.swift | 261 +++++++++++++++--- .../SyntheticMouseClickDelivery.swift | 66 ++++- .../SyntheticMouseClickDeliveryTests.swift | 190 +++++++++++++ src/main/runtime/rpc/errors.test.ts | 19 ++ src/main/runtime/rpc/errors.ts | 2 +- .../computer-use-error-recovery.test.ts | 13 + src/shared/computer-use-error-recovery.ts | 9 +- tests/e2e/computer-mac.e2e.ts | 85 +----- .../computer-coordinate-click-driver.ts | 131 +++++++++ 10 files changed, 674 insertions(+), 105 deletions(-) create mode 100644 tests/e2e/helpers/computer-coordinate-click-driver.ts diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index 32fdd94e938..725f17fdf4c 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -30,7 +30,8 @@ describe('computer-use modifier safety', () => { expect(mouseInput).toContain('event.flags = flags') // Every click event flows through the shared delivery plan and carries // the modifier flags on the mouse event itself. - expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)') + expect(clickInput).toContain('SyntheticMouseClickDelivery.deliver(') + expect(clickInput).toContain('currentSyntheticClickRecipient(') expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 7236f7bab2c..7ecbdbf004a 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -729,10 +729,16 @@ final class Provider { let snapshot = try currentSnapshot(params: params) let button = params["mouseButton"]?.string ?? "left" let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount") + guard count <= SyntheticMouseClickDelivery.maxClickCount else { + throw ProviderError.coded( + "invalid_argument", + "clickCount must be at most \(SyntheticMouseClickDelivery.maxClickCount)" + ) + } let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string) // Why: agents expect a click into a target app to make the next // keyboard action safe, even when the click uses an AX action path. - recoverWindow(snapshot.app) + recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds) if let elementIndex = try optionalInteger(params, "elementIndex") { let record = try element(snapshot, elementIndex) if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) { @@ -743,7 +749,8 @@ final class Provider { at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot ) return actionMetadata( path: "synthetic", @@ -758,7 +765,8 @@ final class Provider { at: point, button: mouseButton(button), count: count, - modifiers: modifiers + modifiers: modifiers, + targetWindow: snapshot ) return actionMetadata( path: "synthetic", @@ -1123,6 +1131,124 @@ private func isTargetWindowFocused(_ snapshot: Snapshot) -> Bool { return !intersection.isNull && intersection.area >= min(frame.area, snapshot.windowBounds.area) * 0.75 } +private func currentSyntheticClickRecipient( + snapshot: Snapshot, + point: CGPoint +) -> SyntheticMouseClickDelivery.Recipient? { + let target = syntheticClickRecipient(pid: snapshot.app.pid, windowId: snapshot.windowId) + var cachedTargetCandidates: [WindowCandidate]? + func targetCandidates() -> [WindowCandidate] { + if let cachedTargetCandidates { return cachedTargetCandidates } + let candidates = WindowCapture.candidates(pid: snapshot.app.pid) + cachedTargetCandidates = candidates + return candidates + } + if let focused = focusedSyntheticClickRecipient( + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) { + guard focused == target else { return focused } + } else { + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == snapshot.app.pid else { + return nil + } + } + return hitTestSyntheticClickRecipient( + at: point, + targetPID: snapshot.app.pid, + targetCandidates: targetCandidates + ) +} + +private func focusedSyntheticClickRecipient( + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + guard let focusedApp = copyElement(systemWide, kAXFocusedApplicationAttribute as String), + let ownerPID = pidAttribute(focusedApp), + let focusedWindow = copyElement(systemWide, kAXFocusedWindowAttribute as String) ?? + copyElement(focusedApp, kAXFocusedWindowAttribute as String) + else { + return nil + } + if let windowId = windowNumber(focusedWindow) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(focusedWindow), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func hitTestSyntheticClickRecipient( + at point: CGPoint, + targetPID: pid_t, + targetCandidates: () -> [WindowCandidate] +) -> SyntheticMouseClickDelivery.Recipient? { + let systemWide = AXUIElementCreateSystemWide() + var hitElement: AXUIElement? + guard AXUIElementCopyElementAtPosition( + systemWide, + Float(point.x), + Float(point.y), + &hitElement + ) == .success, + let hitElement, + let ownerPID = pidAttribute(hitElement), + let window = containingWindow(hitElement) + else { + return nil + } + if let windowId = windowNumber(window) { + return syntheticClickRecipient(pid: ownerPID, windowId: windowId) + } + guard ownerPID == targetPID else { return nil } + guard let frame = absoluteFrame(window), + let candidate = SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: targetCandidates(), + matching: { + windowFramesMatch($0.bounds, frame) + } + ) + else { + return nil + } + return syntheticClickRecipient(pid: ownerPID, windowId: candidate.windowId) +} + +private func containingWindow(_ element: AXUIElement) -> AXUIElement? { + var current = element + for _ in 0..<64 { + if stringAttribute(current, kAXRoleAttribute as String) == kAXWindowRole as String { + return current + } + if let window = copyElement(current, kAXWindowAttribute as String) { + return window + } + guard let parent = copyElement(current, kAXParentAttribute as String) else { + return nil + } + current = parent + } + return nil +} + +private func syntheticClickRecipient( + pid: pid_t, + windowId: CGWindowID +) -> SyntheticMouseClickDelivery.Recipient { + SyntheticMouseClickDelivery.Recipient(ownerPID: pid, windowID: windowId) +} + private func requireTargetWindowFocused(_ snapshot: Snapshot, restoreWindowRequested: Bool) throws { guard let failure = KeyboardInputSafety.syntheticInputFocusFailure( targetWindowFocused: isTargetWindowFocused(snapshot), @@ -1163,14 +1289,44 @@ private func matchingWindow(appElement: AXUIElement, capture: WindowCapture, foc } ?? focused } -private func recoverWindow(_ app: AppDescriptor) { +private func recoverWindow( + _ app: AppDescriptor, + windowId: CGWindowID? = nil, + windowBounds: CGRect? = nil +) { _ = app.app.unhide() _ = app.app.activate(options: [.activateAllWindows]) if let bundleId = app.bundleId { openBundle(bundleId) } let appElement = AXUIElementCreateApplication(app.pid) - if let window = copyElement(appElement, kAXFocusedWindowAttribute as String) ?? copyArray(appElement, kAXWindowsAttribute as String)?.first { + let focusedWindow = copyElement(appElement, kAXFocusedWindowAttribute as String) + var cachedWindows: [AXUIElement]? + func windows() -> [AXUIElement] { + if let cachedWindows { return cachedWindows } + let value = copyArray(appElement, kAXWindowsAttribute as String) ?? [] + cachedWindows = value + return value + } + let targetWindow: AXUIElement? + if let focusedWindow, + (windowId == nil && windowBounds == nil || windowMatchesCapture( + focusedWindow, + windowId: windowId, + windowBounds: windowBounds + )) { + targetWindow = focusedWindow + } else { + let exactWindow = windowId.flatMap { targetId in + windows().first { windowNumber($0) == targetId } + } + targetWindow = exactWindow ?? windowBounds.flatMap { targetBounds in + windows().first { window in + absoluteFrame(window).map { windowFramesMatch($0, targetBounds) } == true + } + } + } + if let window = targetWindow ?? focusedWindow ?? windows().first { _ = AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanFalse) _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString) _ = AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue) @@ -1179,6 +1335,24 @@ private func recoverWindow(_ app: AppDescriptor) { Thread.sleep(forTimeInterval: 0.4) } +private func windowMatchesCapture( + _ window: AXUIElement, + windowId: CGWindowID?, + windowBounds: CGRect? +) -> Bool { + if let windowId, windowNumber(window) == windowId { return true } + guard let windowBounds, let frame = absoluteFrame(window) else { return false } + return windowFramesMatch(frame, windowBounds) +} + +private func windowFramesMatch(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + let tolerance: CGFloat = 2 + return abs(lhs.minX - rhs.minX) <= tolerance && + abs(lhs.minY - rhs.minY) <= tolerance && + abs(lhs.width - rhs.width) <= tolerance && + abs(lhs.height - rhs.height) <= tolerance +} + private func openBundle(_ bundleId: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") @@ -2294,7 +2468,8 @@ private enum Input { at point: CGPoint, button: MouseButton, count: Int, - modifiers: [KeyModifier] + modifiers: [KeyModifier], + targetWindow: Snapshot ) throws { guard let source = CGEventSource(stateID: .combinedSessionState) else { throw ProviderError.coded("accessibility_error", "failed to create event source") @@ -2302,32 +2477,56 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - // Why HID tap + pacing: see SyntheticMouseClickDelivery (STA-3433). - for step in SyntheticMouseClickDelivery.steps(clickCount: count) { - let type: CGEventType - switch step { - case .move: - type = .mouseMoved - case .buttonDown: - type = button.downEvent - case .buttonUp: - type = button.upEvent + let target = syntheticClickRecipient(pid: targetWindow.app.pid, windowId: targetWindow.windowId) + do { + try SyntheticMouseClickDelivery.deliver( + clickCount: count, + target: target, + currentRecipient: { + currentSyntheticClickRecipient(snapshot: targetWindow, point: point) + }, + makeEvent: { step in + let type: CGEventType + switch step { + case .move: + type = .mouseMoved + case .buttonDown: + type = button.downEvent + case .buttonUp: + type = button.upEvent + } + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type, + mouseCursorPosition: point, + mouseButton: button.cgButton + ) else { + throw ProviderError.coded("accessibility_error", "failed to create mouse event") + } + event.flags = flags + let clickState = SyntheticMouseClickDelivery.clickState(for: step) + if clickState > 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + return event + }, + post: { $0.post(tap: .cghidEventTap) }, + pause: { _ = usleep($0) } + ) + } catch let failure as SyntheticMouseClickDelivery.FenceFailure { + switch failure { + case let .recipientChanged(expected, actual, deliveredPresses): + let actualDescription = actual.map { + "pid \($0.ownerPID) window \($0.windowID)" + } ?? "no focused window" + let recovery = deliveredPresses == 0 + ? "bring the target window forward, run get-app-state again, and retry" + : "\(deliveredPresses) press(es) may already have been delivered; run get-app-state and verify state before retrying" + throw ProviderError.coded( + "window_not_focused", + "coordinate click aborted because target pid \(expected.ownerPID) window \(expected.windowID) is no longer the focused topmost recipient (current: \(actualDescription)); \(recovery)" + ) } - guard let event = CGEvent( - mouseEventSource: source, - mouseType: type, - mouseCursorPosition: point, - mouseButton: button.cgButton - ) else { - throw ProviderError.coded("accessibility_error", "failed to create mouse event") - } - event.flags = flags - let clickState = SyntheticMouseClickDelivery.clickState(for: step) - if clickState > 0 { - event.setIntegerValueField(.mouseEventClickState, value: clickState) - } - event.post(tap: .cghidEventTap) - usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds) } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift index d61e56af35c..13aa6e15ced 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -6,6 +6,22 @@ /// happens). The window server also drops a mouseUp posted back-to-back /// with its mouseDown, so consecutive events need a pause between them. public enum SyntheticMouseClickDelivery { + public static let maxClickCount = 3 + + public struct Recipient: Equatable, Sendable { + public let ownerPID: Int32 + public let windowID: UInt32 + + public init(ownerPID: Int32, windowID: UInt32) { + self.ownerPID = ownerPID + self.windowID = windowID + } + } + + public enum FenceFailure: Error, Equatable { + case recipientChanged(expected: Recipient, actual: Recipient?, deliveredPresses: Int) + } + public enum Step: Equatable { case move case buttonDown(pressIndex: Int) @@ -21,7 +37,7 @@ public enum SyntheticMouseClickDelivery { /// clicks instead of independent single clicks. public static func steps(clickCount: Int) -> [Step] { var steps: [Step] = [.move] - for press in 1...max(clickCount, 1) { + for press in 1...min(max(clickCount, 1), maxClickCount) { steps.append(.buttonDown(pressIndex: press)) steps.append(.buttonUp(pressIndex: press)) } @@ -37,4 +53,52 @@ public enum SyntheticMouseClickDelivery { return Int64(pressIndex) } } + + public static func uniqueWindowCandidate( + from candidates: [Candidate], + matching predicate: (Candidate) -> Bool + ) -> Candidate? { + var match: Candidate? + for candidate in candidates where predicate(candidate) { + guard match == nil else { return nil } + match = candidate + } + return match + } + + public static func deliver( + clickCount: Int, + target: Recipient, + currentRecipient: () -> Recipient?, + makeEvent: (Step) throws -> Event, + post: (Event) -> Void, + pause: (UInt32) -> Void + ) throws { + post(try makeEvent(.move)) + pause(interEventPauseMicroseconds) + for pressIndex in 1...min(max(clickCount, 1), maxClickCount) { + let beforeDown = currentRecipient() + guard beforeDown == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: beforeDown, + deliveredPresses: pressIndex - 1 + ) + } + let down = try makeEvent(.buttonDown(pressIndex: pressIndex)) + let up = try makeEvent(.buttonUp(pressIndex: pressIndex)) + post(down) + pause(interEventPauseMicroseconds) + post(up) + let afterUp = currentRecipient() + guard afterUp == target else { + throw FenceFailure.recipientChanged( + expected: target, + actual: afterUp, + deliveredPresses: pressIndex + ) + } + pause(interEventPauseMicroseconds) + } + } } diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift index d80a5e9e8ee..5870e740880 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -29,6 +29,18 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase { } } + func testExcessiveClickCountIsCappedAtTripleClick() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: Int.max), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + .buttonDown(pressIndex: 3), .buttonUp(pressIndex: 3), + ] + ) + } + func testClickStateMatchesPressIndexAndSkipsMove() { XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) @@ -40,4 +52,182 @@ final class SyntheticMouseClickDeliveryTests: XCTestCase { // turning the click into a hover-only no-op (STA-3433). XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) } + + func testAmbiguousWindowFrameFallbackDoesNotSelectRecipient() { + let candidates = [(windowID: 101, frame: 7), (windowID: 202, frame: 7)] + + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 7 } + )) + XCTAssertNil(SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.frame == 8 } + )) + XCTAssertEqual( + SyntheticMouseClickDelivery.uniqueWindowCandidate( + from: candidates, + matching: { $0.windowID == 101 } + )?.windowID, + 101 + ) + } + + func testRecipientChangeBeforeMouseDownPostsNoClickAndReportsBothWindows() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { intruder }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 0) + ) + } + XCTAssertEqual(posted, [.move]) + } + + func testRecipientChangeAfterMouseUpStopsUntilStateIsVerified() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, intruder, target, target] + var firstAttempt: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { firstAttempt.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(firstAttempt, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + + var retry: [SyntheticMouseClickDelivery.Step] = [] + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { retry.append($0) }, + pause: { _ in } + ) + XCTAssertEqual(retry, [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)]) + } + + func testMouseUpPostsBeforeSecondRecipientCheck() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var trace: [String] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { + trace.append("recipient") + return target + }, + makeEvent: { $0 }, + post: { + switch $0 { + case .move: + trace.append("move") + case .buttonDown: + trace.append("down") + case .buttonUp: + trace.append("up") + } + }, + pause: { _ in } + ) + + XCTAssertEqual(trace, ["move", "recipient", "down", "up", "recipient"]) + } + + func testRecipientChangeBeforeLaterPressReportsCompletedPresses() { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + let intruder = SyntheticMouseClickDelivery.Recipient(ownerPID: 52, windowID: 202) + var recipients = [target, target, intruder] + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError( + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { recipients.removeFirst() }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + ) { error in + XCTAssertEqual( + error as? SyntheticMouseClickDelivery.FenceFailure, + .recipientChanged(expected: target, actual: intruder, deliveredPresses: 1) + ) + } + XCTAssertEqual(posted, [ + .move, + .buttonDown(pressIndex: 1), + .buttonUp(pressIndex: 1), + ]) + } + + func testMultiClickRevalidatesBeforeEveryPressAndAfterEveryRelease() throws { + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var validationCount = 0 + var posted: [SyntheticMouseClickDelivery.Step] = [] + + try SyntheticMouseClickDelivery.deliver( + clickCount: 2, + target: target, + currentRecipient: { + validationCount += 1 + return target + }, + makeEvent: { $0 }, + post: { posted.append($0) }, + pause: { _ in } + ) + + XCTAssertEqual(validationCount, 4) + XCTAssertEqual(posted, SyntheticMouseClickDelivery.steps(clickCount: 2)) + } + + func testButtonPairIsPreparedBeforeMouseDownPosts() { + enum PreparationFailure: Error { case mouseUp } + let target = SyntheticMouseClickDelivery.Recipient(ownerPID: 41, windowID: 101) + var posted: [SyntheticMouseClickDelivery.Step] = [] + + XCTAssertThrowsError(try SyntheticMouseClickDelivery.deliver( + clickCount: 1, + target: target, + currentRecipient: { target }, + makeEvent: { step in + if case .buttonUp = step { throw PreparationFailure.mouseUp } + return step + }, + post: { posted.append($0) }, + pause: { _ in } + )) + XCTAssertEqual(posted, [.move]) + } } diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index ddd0541c57b..23a6caaed42 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -137,6 +137,25 @@ describe('mapRuntimeError', () => { }) }) + it('does not recommend a blind retry after a coordinate press may have landed', () => { + const message = + 'coordinate click aborted because the recipient changed; 1 press(es) may already have been delivered' + const error = Object.assign(new Error(message), { code: 'window_not_focused' }) + + const response = mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, error) + + expect(response.error).toMatchObject({ + code: 'window_not_focused', + message, + data: { + nextSteps: [ + expect.stringContaining('verify whether the intended action already occurred'), + expect.stringContaining('Do not retry the click if it already took effect') + ] + } + }) + }) + it('preserves structured lineage error codes and data for CLI recovery hints', () => { const response = mapRuntimeError( 'req_1', diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 930a056a6ac..a2e02e6ccc4 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -108,7 +108,7 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow COMPUTER_PASSTHROUGH_CODES.has((error as { code: string }).code) ) { const code = (error as { code: string }).code - return errorResponse(id, meta, code, message, computerErrorData(code)) + return errorResponse(id, meta, code, message, computerErrorData(code, message)) } if ( error instanceof Error && diff --git a/src/shared/computer-use-error-recovery.test.ts b/src/shared/computer-use-error-recovery.test.ts index 20c870cf300..dbeaf58af27 100644 --- a/src/shared/computer-use-error-recovery.test.ts +++ b/src/shared/computer-use-error-recovery.test.ts @@ -40,6 +40,19 @@ describe('computerUseErrorRecoveryData', () => { ]) }) + it('requires state verification when a focus failure may follow a delivered press', () => { + const recovery = computerUseErrorRecoveryData( + 'window_not_focused', + 'coordinate click aborted; 1 press(es) may already have been delivered' + ) + + expect(recovery?.nextSteps).toEqual([ + expect.stringContaining('verify whether the intended action already occurred'), + expect.stringContaining('Do not retry the click if it already took effect') + ]) + expect(recovery?.nextSteps.join('\n')).not.toContain('Retry once with `--restore-window`') + }) + it('keeps missing web app recovery within computer-use desktop app targeting', () => { const recovery = computerUseErrorRecoveryData('app_not_found') diff --git a/src/shared/computer-use-error-recovery.ts b/src/shared/computer-use-error-recovery.ts index 7d5434934a0..7803738fb1a 100644 --- a/src/shared/computer-use-error-recovery.ts +++ b/src/shared/computer-use-error-recovery.ts @@ -3,7 +3,8 @@ export type ComputerUseErrorRecoveryData = { } export function computerUseErrorRecoveryData( - code: string + code: string, + message?: string ): ComputerUseErrorRecoveryData | undefined { switch (code) { case 'app_not_found': @@ -24,6 +25,12 @@ export function computerUseErrorRecoveryData( 'If no window is listed, open or focus the app first; `orca computer` does not launch closed desktop apps.' ) case 'window_not_focused': + if (message?.includes('may already have been delivered')) { + return recoverWith( + 'Run `orca computer get-app-state --app --json` and verify whether the intended action already occurred before retrying.', + 'Do not retry the click if it already took effect; otherwise bring the target window forward and use fresh state before trying again.' + ) + } return recoverWith( 'Retry once with `--restore-window`.', 'If `--restore-window` was already used, stop retrying restore; bring the app forward manually, check permissions, or prefer `set-value` for editable fields.' diff --git a/tests/e2e/computer-mac.e2e.ts b/tests/e2e/computer-mac.e2e.ts index 6aa162cee40..5c6d4e381fe 100644 --- a/tests/e2e/computer-mac.e2e.ts +++ b/tests/e2e/computer-mac.e2e.ts @@ -13,6 +13,10 @@ import { parseJsonOutput, runOrcaCli } from './helpers/computer-driver' +import { + clickCapturedTextEditOpenDialog, + doubleClickTextEditWord +} from './helpers/computer-coordinate-click-driver' const isMac = process.platform === 'darwin' const e2eOptIn = process.env.ORCA_COMPUTER_E2E === '1' @@ -72,81 +76,22 @@ describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => }) test('coordinate double-click activates a control, not just hover (STA-3433)', async () => { - // Ten identical short lines: any first-lines click hits a word, and the - // whole document stays inside the treeText value preview after editing. - const filler = Array(10).fill('wordword').join('\n') - await runOrcaCli([ - 'computer', - 'hotkey', - '--app', - 'TextEdit', - '--key', - 'CmdOrCtrl+A', - '--no-screenshot' - ]) - await runOrcaCli([ - 'computer', - 'paste-text', - '--app', - 'TextEdit', - '--text', - filler, - '--no-screenshot' - ]) + const result = await doubleClickTextEditWord() - // Word-select via coordinates: 40px in, 70px down lands inside a - // "wordword" on one of the first lines whether or not the ruler is shown. - const clicked = parseJsonOutput<{ result: ComputerActionResult }>( - ( - await runOrcaCli([ - 'computer', - 'click', - '--app', - 'TextEdit', - '--x', - '40', - '--y', - '70', - '--click-count', - '2', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - expect(clicked.result.action?.path).toBe('synthetic') - expect(clicked.result.action?.verification).toMatchObject({ + expect(result.action?.path).toBe('synthetic') + expect(result.action?.verification).toMatchObject({ state: 'unverified', reason: 'synthetic_input' }) + expect(result.replacedWord).toBe(true) + }) - const marker = `zz${Date.now()}zz` - await runOrcaCli([ - 'computer', - 'type-text', - '--app', - 'TextEdit', - '--text', - marker, - '--no-screenshot' - ]) - - const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( - ( - await runOrcaCli([ - 'computer', - 'get-app-state', - '--app', - 'TextEdit', - '--no-screenshot', - '--json' - ]) - ).stdout - ) - // The double-click selected a mid-document word, so the typed marker must - // be followed by more filler. A dropped press leaves the caret at the end - // of the document and the marker only ever appends (the STA-3433 no-op). - expect(after.result.snapshot.treeText).toMatch(new RegExp(`${marker}\\s+wordword`)) + test('coordinate click reaches the captured native dialog window', async () => { + expect(await clickCapturedTextEditOpenDialog()).toMatchObject({ + clickPath: 'synthetic', + dialogClosed: true, + dialogWasNew: true + }) }) test('paste-text and hotkey verify TextEdit text replacement', async () => { diff --git a/tests/e2e/helpers/computer-coordinate-click-driver.ts b/tests/e2e/helpers/computer-coordinate-click-driver.ts new file mode 100644 index 00000000000..6b17e7bca15 --- /dev/null +++ b/tests/e2e/helpers/computer-coordinate-click-driver.ts @@ -0,0 +1,131 @@ +import type { + ComputerActionResult, + ComputerSnapshotResult +} from '../../../src/shared/runtime-types' +import { parseJsonOutput, runOrcaCli } from './computer-driver' + +export async function doubleClickTextEditWord(): Promise<{ + action: ComputerActionResult['action'] + replacedWord: boolean +}> { + const filler = Array(10).fill('wordword').join('\n') + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+A', + '--no-screenshot' + ]) + await runOrcaCli([ + 'computer', + 'paste-text', + '--app', + 'TextEdit', + '--text', + filler, + '--no-screenshot' + ]) + + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--x', + '40', + '--y', + '70', + '--click-count', + '2', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const marker = `zz${Date.now()}zz` + await runOrcaCli([ + 'computer', + 'type-text', + '--app', + 'TextEdit', + '--text', + marker, + '--no-screenshot' + ]) + + const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( + ( + await runOrcaCli([ + 'computer', + 'get-app-state', + '--app', + 'TextEdit', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + return { + action: clicked.result.action, + replacedWord: new RegExp(`${marker}\\s+wordword`).test(after.result.snapshot.treeText) + } +} + +export async function clickCapturedTextEditOpenDialog(): Promise<{ + clickPath: string | undefined + dialogClosed: boolean + dialogWasNew: boolean +}> { + const before = parseJsonOutput<{ + result: { windows: { id?: number | null }[] } + }>((await runOrcaCli(['computer', 'list-windows', '--app', 'TextEdit', '--json'])).stdout) + const existingWindowIds = new Set(before.result.windows.map((window) => window.id)) + + const opened = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+O', + '--restore-window', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const dialog = opened.result.snapshot.window + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--window-id', + String(dialog.id), + '--x', + String(dialog.width - 140), + '--y', + String(dialog.height - 30), + '--no-screenshot', + '--json' + ]) + ).stdout + ) + const after = parseJsonOutput<{ + result: { windows: { id?: number | null }[] } + }>((await runOrcaCli(['computer', 'list-windows', '--app', 'TextEdit', '--json'])).stdout) + + return { + clickPath: clicked.result.action?.path, + dialogClosed: !after.result.windows.some((window) => window.id === dialog.id), + dialogWasNew: !existingWindowIds.has(dialog.id) + } +} From 7e60b338ea0bcd32f3702fefe697646adcbf5427 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:19:04 -0700 Subject: [PATCH 72/88] fix(persistence): fail closed when safeStorage cannot encrypt (#12983) Co-authored-by: Jinwoo-H --- ...tence-protected-secret-fail-closed.test.ts | 427 ++++++++++++++++++ ...stence-protected-secret-write-race.test.ts | 106 +++++ .../persistence-proxy-secret-recovery.test.ts | 22 +- src/main/persistence-single-serialize.test.ts | 4 +- src/main/persistence.ts | 202 ++++++--- src/main/protected-secret-persistence.test.ts | 66 +++ src/main/protected-secret-persistence.ts | 139 ++++++ 7 files changed, 883 insertions(+), 83 deletions(-) create mode 100644 src/main/persistence-protected-secret-fail-closed.test.ts create mode 100644 src/main/persistence-protected-secret-write-race.test.ts create mode 100644 src/main/protected-secret-persistence.test.ts create mode 100644 src/main/protected-secret-persistence.ts diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts new file mode 100644 index 00000000000..42d348af61c --- /dev/null +++ b/src/main/persistence-protected-secret-fail-closed.test.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' + +type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable' + +const testState = { dir: '' } +const cipherState = { + availability: 'available' as 'available' | 'throws' | 'unavailable', + encryptionThrows: false, + decryptionThrows: false +} + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availability === 'throws') { + throw new Error('keychain access denied') + } + return cipherState.availability === 'available' + }, + encryptString: (plaintext: string) => { + if (cipherState.encryptionThrows) { + throw new Error('keychain encryption failed') + } + return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8') + }, + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptionThrows) { + throw new Error('keychain decryption failed') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +type ProtectedState = { + settings: { + httpProxyUrl: string + httpProxyBypassRules: string + opencodeSessionCookie: string + } + ui: { browserKagiSessionLink: string | null } + sshPtyConsumerRecoveries: { ownerLease: string }[] +} + +function readState(path = dataFile()): ProtectedState { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +const ORIGINAL = { + proxy: 'http://old-user:old-pass@proxy.test:8080', + cookie: 'old-opencode-cookie', + kagi: 'https://kagi.test/session/old-token', + ownerLease: `old-ssh-owner-lease-${'x'.repeat(480)}` +} as const +const PENDING = { + proxy: 'http://new-user:new-pass@proxy.test:8080', + cookie: 'new-opencode-cookie', + kagi: 'https://kagi.test/session/new-token', + ownerLease: `new-ssh-owner-lease-${'y'.repeat(480)}` +} as const + +function setFailure(mode: FailureMode): void { + cipherState.availability = + mode === 'availability-throws' ? 'throws' : mode === 'unavailable' ? 'unavailable' : 'available' + cipherState.encryptionThrows = mode === 'encryption-throws' +} + +async function writeProtectedState( + store: Awaited>, + values: typeof ORIGINAL | typeof PENDING, + bypassRules: string +): Promise { + store.updateSettings({ + httpProxyUrl: values.proxy, + httpProxyBypassRules: bypassRules, + opencodeSessionCookie: values.cookie + }) + store.updateUI({ browserKagiSessionLink: values.kagi }) + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: values.ownerLease + }) +} + +function expectPlaintextsAbsent(raw: string, values: typeof ORIGINAL | typeof PENDING): void { + for (const plaintext of Object.values(values)) { + expect.soft(raw).not.toContain(plaintext) + } +} + +async function settleSave(store: Awaited>): Promise { + vi.advanceTimersByTime(2_000) + await store.waitForPendingWrite() +} + +describe('protected persistence when safeStorage fails', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-safe-storage-test-')) + cipherState.availability = 'available' + cipherState.encryptionThrows = false + cipherState.decryptionThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'omits newly introduced protected values during a persistent failure: %s', + async (failureMode) => { + setFailure(failureMode) + const store = await createStore() + await writeProtectedState(store, PENDING, 'non-secret-saved') + + const raw = readFileSync(dataFile(), 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(raw, PENDING) + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + expect(persisted.settings.httpProxyBypassRules).toBe('non-secret-saved') + } + ) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'persists the pending protected state after same-instance recovery: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + await writeProtectedState(store, PENDING, 'during-failure') + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + } + ) + + it('honors explicit clears during an outage without later resurrecting ciphertext', async () => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + cipherState.availability = 'unavailable' + + store.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + store.updateUI({ browserKagiSessionLink: null }) + await store.removeSshPtyConsumerRecovery('ssh-1') + await settleSave(store) + + await writeProtectedState(store, PENDING, 'after-clear') + const persisted = readState() + expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.opencodeSessionCookie).toBe('') + expect(persisted.ui.browserKagiSessionLink).toBe('') + expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + + cipherState.availability = 'available' + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe('') + expect(restarted.getSettings().opencodeSessionCookie).toBe('') + expect(restarted.getUI().browserKagiSessionLink).toBe('') + expect(restarted.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + }) + + it('persists clears after a healthy save preserves sealed ciphertext', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'healthy-preserve' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + sealed.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' }) + await settleSave(sealed) + expect(readState().settings.httpProxyUrl).toBe('') + expect(readState().settings.opencodeSessionCookie).toBe('') + }) + + it('JSON-escapes retained legacy plaintext while storage is unavailable', async () => { + const legacyCookie = 'legacy-"cookie\\value' + writeFileSync(dataFile(), JSON.stringify({ settings: { opencodeSessionCookie: legacyCookie } })) + cipherState.availability = 'unavailable' + const store = await createStore() + + store.updateSettings({ opencodeSessionCookie: PENDING.cookie }) + await settleSave(store) + + expect(readState().settings.opencodeSessionCookie).toBe(legacyCookie) + }) + + it('evicts ciphertext when decrypted SSH recovery validation rejects the record', async () => { + const oversizedLease = 'z'.repeat(513) + const encryptedLease = Buffer.from(`enc:${randomUUID()}:${oversizedLease}`).toString('base64') + writeFileSync( + dataFile(), + JSON.stringify({ + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: encryptedLease + } + ] + }) + ) + const store = await createStore() + expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + cipherState.availability = 'unavailable' + await store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 2, + ownerGeneration: 2, + ownerLease: 'replacement-owner-lease' + }) + + expect(readState().sshPtyConsumerRecoveries[0]?.ownerLease).toBe('') + }) + + it('keeps loaded ciphertext sealed through same-instance recovery', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.availability = 'unavailable' + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'during-outage' }) + await settleSave(sealed) + expect(readState().settings.opencodeSessionCookie).toBe( + originalCiphertext.settings.opencodeSessionCookie + ) + + cipherState.availability = 'available' + sealed.updateSettings({ httpProxyBypassRules: 'after-recovery' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('after-recovery') + }) + + it('keeps undecryptable ciphertext sealed from consumers and later saves', async () => { + const initial = await createStore() + await writeProtectedState(initial, ORIGINAL, 'before') + const originalCiphertext = readState() + + cipherState.decryptionThrows = true + const sealed = await createStore() + expect(sealed.getSettings().httpProxyUrl).toBe('') + expect(sealed.getSettings().opencodeSessionCookie).toBe('') + expect(sealed.getUI().browserKagiSessionLink).toBe('') + expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull() + + sealed.updateSettings({ httpProxyBypassRules: 'decryption-failed' }) + await settleSave(sealed) + expect(readState()).toMatchObject({ + settings: { + httpProxyUrl: originalCiphertext.settings.httpProxyUrl, + opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie + }, + ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink }, + sshPtyConsumerRecoveries: [ + { ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease } + ] + }) + + cipherState.decryptionThrows = false + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + }) + + it('accepts validated legacy plaintext for OpenCode and SSH migration', async () => { + const legacyCookie = 'auth=Fe26.2**legacy-token' + const legacyOwnerLease = '9ab3f53d-0de9-4b80-af38-0cc15f62a6ba' + writeFileSync( + dataFile(), + JSON.stringify({ + settings: { opencodeSessionCookie: legacyCookie }, + sshPtyConsumerRecoveries: [ + { + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: legacyOwnerLease + } + ] + }) + ) + + const store = await createStore() + expect(store.getSettings().opencodeSessionCookie).toBe(legacyCookie) + expect(store.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(legacyOwnerLease) + }) + + it.each(['availability-throws', 'encryption-throws', 'unavailable'])( + 'saves non-secrets without exposing or destroying protected values: %s', + async (failureMode) => { + const store = await createStore() + await writeProtectedState(store, ORIGINAL, 'before') + const originalCiphertext = readState() + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), ORIGINAL) + + vi.advanceTimersByTime(60 * 60 * 1_000 + 1) + setFailure(failureMode) + await writeProtectedState(store, PENDING, 'during-failure') + + const primaryRaw = readFileSync(dataFile(), 'utf-8') + const backupRaw = readFileSync(`${dataFile()}.bak.0`, 'utf-8') + const persisted = readState() + expectPlaintextsAbsent(primaryRaw, PENDING) + expectPlaintextsAbsent(backupRaw, PENDING) + expect.soft(persisted.settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl) + expect + .soft(persisted.settings.opencodeSessionCookie) + .toBe(originalCiphertext.settings.opencodeSessionCookie) + expect + .soft(persisted.ui.browserKagiSessionLink) + .toBe(originalCiphertext.ui.browserKagiSessionLink) + expect + .soft(persisted.sshPtyConsumerRecoveries[0]?.ownerLease) + .toBe(originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease) + expect.soft(persisted.settings.httpProxyBypassRules).toBe('during-failure') + + const loadedDuringFailure = await createStore() + expect(loadedDuringFailure.getSettings().httpProxyBypassRules).toBe('during-failure') + await settleSave(loadedDuringFailure) + expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), PENDING) + + cipherState.availability = 'available' + cipherState.encryptionThrows = false + const recovered = await createStore() + expect(recovered.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy) + expect(recovered.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie) + expect(recovered.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi) + expect(recovered.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease) + expect(recovered.getSettings().httpProxyBypassRules).toBe('during-failure') + + await writeProtectedState(recovered, PENDING, 'recovered') + const restarted = await createStore() + expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy) + expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie) + expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi) + expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease) + expect(restarted.getSettings().httpProxyBypassRules).toBe('recovered') + } + ) +}) diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts new file mode 100644 index 00000000000..20bbe78d5cf --- /dev/null +++ b/src/main/persistence-protected-secret-write-race.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const testState = { dir: '' } +const cipherState = { available: true } + +const renameGate = vi.hoisted(() => ({ + sourcePrefix: '', + release: null as Promise | null, + started: null as (() => void) | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (renameGate.release && source.startsWith(renameGate.sourcePrefix)) { + const release = renameGate.release + renameGate.release = null + renameGate.started?.() + await release + } + return actual.rename(source, destination) + } + } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +describe('protected-secret async write retention', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-protected-secret-write-race-')) + cipherState.available = true + renameGate.sourcePrefix = join(testState.dir, 'orca-data.json') + renameGate.release = null + renameGate.started = null + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('does not retain ciphertext from a superseded async secret write', async () => { + const store = await createStore() + store.updateSettings({ opencodeSessionCookie: 'durable-cookie' }) + vi.advanceTimersByTime(1_000) + await store.waitForPendingWrite() + + const renameRelease = deferred() + const renameStarted = deferred() + renameGate.release = renameRelease.promise + renameGate.started = renameStarted.resolve + + store.updateSettings({ opencodeSessionCookie: 'intermediate-cookie' }) + vi.advanceTimersByTime(1_000) + await renameStarted.promise + + store.updateSettings({ opencodeSessionCookie: 'replacement-cookie' }) + cipherState.available = false + vi.advanceTimersByTime(1_000) + renameRelease.resolve() + await store.waitForPendingWrite() + + cipherState.available = true + const restarted = await createStore() + expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie') + }) +}) diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts index bc6e03483bb..769ebec69ba 100644 --- a/src/main/persistence-proxy-secret-recovery.test.ts +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -2,9 +2,9 @@ // On macOS a keychain reset/denial makes decryptString throw at load, and the // raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings // silently fell back to DIRECT and the garbage re-persisted forever. These tests -// pin the recovery contract: undecryptable values are cleared (never applied, -// never re-saved as garbage), plaintext values survive as the upgrade path, and -// a throwing safeStorage.isEncryptionAvailable() cannot kill the whole save. +// pin the recovery contract: undecryptable values stay sealed (never applied +// or destroyed), plaintext values survive as the upgrade path, and safeStorage +// failures cannot expose the proxy secret or kill unrelated saves. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -131,8 +131,9 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { }) }) - it('clears an undecryptable httpProxyUrl instead of surfacing ciphertext as settings', async () => { + it('seals an undecryptable httpProxyUrl without destroying its ciphertext', async () => { await seedConfiguredProxy() + const originalCiphertext = JSON.parse(readFileSync(dataFile(), 'utf-8')).settings.httpProxyUrl // Keychain reset/denial: every decrypt now fails. cipherState.decryptAlwaysThrows = true @@ -142,13 +143,14 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { // Bypass rules are not encrypted and must survive. expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) - // The cleanup must reach disk so garbage never round-trips back in. + // Unrelated durable changes must not erase ciphertext that can recover later. + reloaded.updateSettings({ httpProxyBypassRules: 'localhost' }) vi.advanceTimersByTime(2000) await reloaded.waitForPendingWrite() const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { httpProxyUrl: string } } - expect(persisted.settings.httpProxyUrl).toBe('') + expect(persisted.settings.httpProxyUrl).toBe(originalCiphertext) }) it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => { @@ -164,7 +166,7 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) }) - it('still saves and reloads the proxy when safeStorage.isEncryptionAvailable throws', async () => { + it('saves non-secret proxy settings without plaintext when availability throws', async () => { cipherState.availabilityThrows = true const store = await createStore() @@ -172,15 +174,15 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => { vi.advanceTimersByTime(1000) await store.waitForPendingWrite() - // Encryption degraded to plaintext passthrough; the save must not be lost. expect(existsSync(dataFile())).toBe(true) const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { settings: { httpProxyUrl: string; httpProxyBypassRules: string } } - expect(persisted.settings.httpProxyUrl).toBe(PROXY_URL) + expect(persisted.settings.httpProxyUrl).toBe('') expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) const reloaded = await createStore() - expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(reloaded.getSettings().httpProxyUrl).toBe('') + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) }) }) diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts index 66c3b4ab0f6..c638ac12224 100644 --- a/src/main/persistence-single-serialize.test.ts +++ b/src/main/persistence-single-serialize.test.ts @@ -145,7 +145,7 @@ describe('persistence single-serialize save guard', () => { expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK) }) - it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => { + it('omits new secrets when encryption is unavailable and still skips an identical state', async () => { cipherState.encryptionAvailable = false const store = await createStore() store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' }) @@ -156,7 +156,7 @@ describe('persistence single-serialize save guard', () => { settings: { opencodeSessionCookie: string; httpProxyUrl: string } } expect(persisted.settings.opencodeSessionCookie).toBe('') - expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl) + expect(persisted.settings.httpProxyUrl).toBe('') const inoBefore = statSync(dataFile()).ino store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 04188206ad7..08697613c31 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { app, safeStorage } from 'electron' +import { app } from 'electron' import { readFileSync, writeFileSync, @@ -39,6 +39,7 @@ import { import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import { normalizeProxyUrl } from '../shared/network-proxy' +import { normalizeKagiSessionLink } from '../shared/browser-url' import type { PersistedState, Project, @@ -280,47 +281,23 @@ import { import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + ProtectedSecretPersistence, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from './protected-secret-persistence' -// Why (STA-3442): isEncryptionAvailable() itself can throw (keychain/API errors, pre-ready -// use); an uncaught throw here failed the entire save/load, silently losing every setting. -function safeStorageEncryptionAvailable(): boolean { - try { - return safeStorage.isEncryptionAvailable() - } catch (err) { - console.warn('[persistence] safeStorage availability check failed:', err) - return false - } +function isLegacyOpenCodeSessionCookie(value: string): boolean { + const trimmed = value.trim() + return ( + trimmed.startsWith('Fe26.2**') || + trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim())) + ) } -function encrypt(plaintext: string): string { - if (!plaintext || !safeStorageEncryptionAvailable()) { - return plaintext - } - try { - return safeStorage.encryptString(plaintext).toString('base64') - } catch (err) { - console.error('[persistence] Encryption failed:', err) - return plaintext - } -} - -function decrypt(ciphertext: string): string { - if (!ciphertext || !safeStorageEncryptionAvailable()) { - return ciphertext - } - try { - return safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) - } catch { - // Why: decrypt failure usually means plaintext (pre-encryption) or a changed keychain; return raw so the cookie survives upgrade. - console.warn( - '[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.' - ) - return ciphertext - } -} - -function decryptOptionalSecret(value: string | null | undefined): string | null { - return value ? decrypt(value) : null +function isLegacySshPtyOwnerLease(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } function retireLegacyInstructionsForClearedTextActionRecipes( @@ -2813,6 +2790,7 @@ export class Store { private pendingGithubCacheWrite: Promise | null = null private readonly staleGithubCacheTempCleanup: Promise private gitUsernameCache = new Map() + private readonly protectedSecrets = new ProtectedSecretPersistence() private loadNeedsSave = false private settingsChangeListeners = new Set< ( @@ -3075,25 +3053,43 @@ export class Store { // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) + parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) } if (parsed.settings?.httpProxyUrl) { - const decryptedProxyUrl = decrypt(parsed.settings.httpProxyUrl) + const decryptedProxy = this.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL // value must not masquerade as a configured proxy (silent DIRECT fallback) or // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if (normalizeProxyUrl(decryptedProxyUrl).ok) { - parsed.settings.httpProxyUrl = decryptedProxyUrl + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext } else { console.warn( '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' ) parsed.settings.httpProxyUrl = '' + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) this.loadNeedsSave = true } } if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) + parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) } parsed.sshPtyConsumerRecoveries = ( Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] @@ -3102,8 +3098,23 @@ export class Store { normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) ) .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) })) - .map((record) => normalizeSshPtyConsumerRecovery(record)) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) .filter((record): record is SshPtyConsumerRecovery => record !== null) // Merge with defaults in case new fields were added @@ -3904,8 +3915,12 @@ export class Store { return durable } - // Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify. - private buildStateToSave(): { payload: string; stateHash: string } { + // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. + private buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { // Why sentinels (not a blob/key string match): the substitution must be // position-exact. A plain search for the ciphertext — or even for a // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an @@ -3915,54 +3930,79 @@ export class Store { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] - const encryptToSentinel = (plaintext: string): string => { - const blob = encrypt(plaintext) - // Deterministic already (empty secret / safeStorage unavailable / encrypt - // failure): blob === plaintext, so no normalization — and no sentinel, - // which also avoids substituting an empty or plaintext-shaped slot. - if (blob === plaintext) { + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { return blob } const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, plaintext }) + secretSubs.push({ sentinel, blob, hashValue }) return sentinel } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ ...record, - ownerLease: encryptToSentinel(record.ownerLease) + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) })), settings: { ...this.state.settings, - opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), - httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.state.settings.httpProxyUrl ?? '' + ) }, ui: { ...this.state.ui, - browserKagiSessionLink: this.state.ui.browserKagiSessionLink - ? encryptToSentinel(this.state.ui.browserKagiSessionLink) - : null + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.state.ui.browserKagiSessionLink + ) } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, plaintext for the guard hash. Function-form replacement keeps - // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped // in `serialized`, so each replace is byte-for-byte position-exact. let payload = serialized let hashInput = serialized - for (const { sentinel, blob, plaintext } of secretSubs) { + for (const { sentinel, blob, hashValue } of secretSubs) { const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => blob) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) } - const stateHash = createHash('sha1').update(hashInput).digest('hex') - return { payload, stateHash } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } } // Why: async writes avoid blocking the main Electron thread on every debounced save. @@ -3971,7 +4011,7 @@ export class Store { return } const gen = this.writeGeneration - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. if (stateHash === this.lastWrittenStateHash) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) @@ -4013,6 +4053,7 @@ export class Store { // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) } else if (renamed) { this.lastWrittenStateHash = null } @@ -4039,7 +4080,7 @@ export class Store { if (this.writesFrozen) { return } - const { payload, stateHash } = this.buildStateToSave() + const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. if (!opts.force && stateHash === this.lastWrittenStateHash) { return @@ -4059,6 +4100,7 @@ export class Store { writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash + this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) this.lastDurableWriteGeneration = Math.max( this.lastDurableWriteGeneration, this.writeGeneration @@ -5724,6 +5766,12 @@ export class Store { options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie) + } + if ('httpProxyUrl' in updates && !updates.httpProxyUrl) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + } // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5948,6 +5996,9 @@ export class Store { } updateUI(updates: Partial): void { + if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) { + this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink) + } const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) const { activeView, ...durableUpdates } = sanitizedUpdates const activeViewChanged = this.activeViewPreference.set(activeView) @@ -6853,6 +6904,7 @@ export class Store { } this.state.sshTargets = nextTargets this.state.sshPtyConsumerRecoveries = nextRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id)) this.scheduleSave() } @@ -7003,6 +7055,7 @@ export class Store { const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId) if (retainedRecoveries.length !== recoveries.length) { this.state.sshPtyConsumerRecoveries = retainedRecoveries + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId)) carrierChanged = true } let setupsChanged = false @@ -7045,6 +7098,12 @@ export class Store { const record = (this.state.sshPtyConsumerRecoveries ?? []).find( (candidate) => candidate.targetId === targetId ) + if ( + record && + this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease) + ) { + return null + } return record ? structuredClone(record) : null } @@ -7068,6 +7127,7 @@ export class Store { return } this.state.sshPtyConsumerRecoveries = next + this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId)) await this.flushSshPtyConsumerRecovery() } diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts new file mode 100644 index 00000000000..815edc5d477 --- /dev/null +++ b/src/main/protected-secret-persistence.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cipherState = { available: true } + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => cipherState.available, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`), + decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length) + } +})) + +describe('ProtectedSecretPersistence', () => { + beforeEach(() => { + cipherState.available = true + }) + + it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => { + const { ProtectedSecretPersistence, sshPtyOwnerLeaseSecretSlot } = + await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slots = Array.from({ length: 100 }, (_, index) => + sshPtyOwnerLeaseSecretSlot(`ssh-${index}`) + ) + + for (const slot of slots) { + expect(secrets.encrypt(slot, 'owner-lease').blob).not.toBe('owner-lease') + secrets.removeRetainedBlob(slot) + } + + cipherState.available = false + for (const slot of slots) { + expect(secrets.encrypt(slot, 'replacement-lease')).toEqual({ blob: '', degraded: true }) + } + }) + + it('keeps unavailable ciphertext sealed across recovery until replacement or clear', async () => { + const { ProtectedSecretPersistence } = await import('./protected-secret-persistence') + const secrets = new ProtectedSecretPersistence() + const slot = 'protected-slot' + const ciphertext = Buffer.from('encrypted:original').toString('base64') + + cipherState.available = false + expect(secrets.decryptWithStatus(slot, ciphertext)).toEqual({ + plaintext: '', + status: 'unavailable' + }) + expect(secrets.isSealed(slot, ciphertext)).toBe(true) + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: true, + hashValue: ciphertext + }) + + cipherState.available = true + expect(secrets.encrypt(slot, '')).toEqual({ + blob: ciphertext, + degraded: false, + hashValue: ciphertext + }) + expect(secrets.encrypt(slot, 'replacement').blob).not.toBe(ciphertext) + + secrets.removeRetainedBlob(slot) + expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false }) + }) +}) diff --git a/src/main/protected-secret-persistence.ts b/src/main/protected-secret-persistence.ts new file mode 100644 index 00000000000..85ab3153043 --- /dev/null +++ b/src/main/protected-secret-persistence.ts @@ -0,0 +1,139 @@ +import { safeStorage } from 'electron' + +export const PROTECTED_SECRET_SLOT = { + opencodeSessionCookie: 'settings.opencodeSessionCookie', + httpProxyUrl: 'settings.httpProxyUrl', + browserKagiSessionLink: 'ui.browserKagiSessionLink' +} as const + +export function sshPtyOwnerLeaseSecretSlot(targetId: string): string { + return `sshPtyConsumerRecoveries.ownerLease:${targetId}` +} + +export type ProtectedSecretDecryption = { + plaintext: string + status: 'decrypted' | 'failed' | 'unavailable' +} + +export type ProtectedSecretRetentionUpdate = { + slot: string + blob: string | null +} + +export type LegacyPlaintextValidator = (value: string) => boolean + +type ProtectedSecretEncryption = { + blob: string + degraded: boolean + hashValue?: string + retentionUpdate?: ProtectedSecretRetentionUpdate +} + +// Preserve prior ciphertext or omit a new secret so unrelated state can still save safely. +export class ProtectedSecretPersistence { + private readonly retainedBlobs = new Map() + private readonly sealedSlots = new Set() + + removeRetainedBlob(slot: string): void { + this.retainedBlobs.delete(slot) + this.sealedSlots.delete(slot) + } + + isSealed(slot: string, value: string): boolean { + return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value + } + + commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void { + for (const update of updates) { + if (update.blob === null) { + this.removeRetainedBlob(update.slot) + } else { + this.retainedBlobs.set(update.slot, update.blob) + this.sealedSlots.delete(update.slot) + } + } + } + + encrypt(slot: string, plaintext: string): ProtectedSecretEncryption { + const retained = this.retainedBlobs.get(slot) ?? '' + if (!plaintext && !retained) { + return { blob: '', degraded: false } + } + if (!this.encryptionAvailable()) { + return { + blob: retained, + degraded: true, + ...(!plaintext && retained ? { hashValue: retained } : {}) + } + } + if (this.isSealed(slot, plaintext) || (!plaintext && this.sealedSlots.has(slot))) { + return { blob: retained, degraded: false, hashValue: retained } + } + if (!plaintext) { + return { + blob: '', + degraded: false, + retentionUpdate: { slot, blob: null } + } + } + try { + const blob = safeStorage.encryptString(plaintext).toString('base64') + return { + blob, + degraded: false, + retentionUpdate: { slot, blob } + } + } catch (err) { + console.error('[persistence] Encryption failed; retaining the prior protected value:', err) + return { blob: retained, degraded: true } + } + } + + decrypt(slot: string, ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator): string { + return this.decryptWithStatus(slot, ciphertext, isLegacyPlaintext).plaintext + } + + decryptWithStatus( + slot: string, + ciphertext: string, + isLegacyPlaintext?: LegacyPlaintextValidator + ): ProtectedSecretDecryption { + if (!ciphertext) { + this.removeRetainedBlob(slot) + return { plaintext: '', status: 'decrypted' } + } + this.retainedBlobs.set(slot, ciphertext) + if (!this.encryptionAvailable()) { + this.sealedSlots.add(slot) + return { plaintext: '', status: 'unavailable' } + } + try { + const decrypted = { + plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')), + status: 'decrypted' as const + } + this.sealedSlots.delete(slot) + return decrypted + } catch { + if (isLegacyPlaintext?.(ciphertext)) { + this.sealedSlots.delete(slot) + console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.') + return { plaintext: ciphertext, status: 'failed' } + } + this.sealedSlots.add(slot) + console.warn( + '[persistence] safeStorage decryption failed; retaining the protected value without exposing it.' + ) + return { plaintext: '', status: 'failed' } + } + } + + private encryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch (err) { + console.warn('[persistence] safeStorage availability check failed:', err) + return false + } + } +} From 2b42de1f522ae3bb8fe397b9217e223e3c5cf6f6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:50:09 -0700 Subject: [PATCH 73/88] fix(orchestration): wake coordinators with mail pointers (#12988) Wake idle Run coordinators with durable orchestration mail pointers while keeping message payloads in the store until check consumes them. Preserve waiter, Cursor, restart, real Codex title, and PTY replacement behavior.\n\nPart of #12953. --- src/main/runtime/orca-runtime.test.ts | 240 +++++++++++++++--- src/main/runtime/orca-runtime.ts | 178 ++++++++----- .../runtime/orchestration/formatter.test.ts | 14 +- src/main/runtime/orchestration/formatter.ts | 5 + .../terminal-send-stale-leaf-liveness.test.ts | 97 +++---- src/shared/agent-title-status.ts | 10 +- src/shared/terminal-output-side-effects.ts | 7 +- .../helpers/orchestration-mail-pane-agent.ts | 6 +- tests/e2e/helpers/orchestration-mail-store.ts | 16 +- .../orchestration-idle-mail-delivery.spec.ts | 132 ++++++++-- .../orchestration-idle-mail-restore.spec.ts | 18 +- 11 files changed, 517 insertions(+), 206 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cabbb9ac044..90b7bb45f81 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1029,6 +1029,11 @@ class InMemoryOrchestrationMessages { private messages: MessageRow[] = [] + private runs = new Map< + string, + { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + >() + insertMessage(msg: { from: string to: string @@ -1084,6 +1089,30 @@ class InMemoryOrchestrationMessages { return this.activeCoordinatorRun } + setRun(run: { + id: string + coordinator_handle: string | null + coordinator_pane_key?: string | null + }): void { + this.runs.set(run.id, { coordinator_pane_key: null, ...run }) + } + + getRun( + id: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return this.runs.get(id) + } + + getCurrentRunForPane( + paneKey: string + ): + | { id: string; coordinator_handle: string | null; coordinator_pane_key: string | null } + | undefined { + return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -18468,23 +18497,24 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) - // Why: the split Enter write lands after the 500ms delay; advance past it before asserting on delivered_at. + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-1', '\r') - // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps delivered_at but must not flip read; only the agent's check consumes, so rows stay unread. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() } }) - it('injects pending orchestration messages into the active coordinator without auto-submitting', async () => { + it('submits the mail pointer in an active coordinator pane', async () => { vi.useFakeTimers() try { const runtime = new OrcaRuntimeService(store) @@ -18512,18 +18542,18 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: hello coordinator') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) - expect(submitWrites).toHaveLength(0) + expect(submitWrites).toHaveLength(1) const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18551,18 +18581,20 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello cursor')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' ) expect(submitWrites).toHaveLength(0) - // Why: Cursor Agent treats injected PTY text as editable prompt input, so the user submits manually but the banner must not replay on the next idle transition. const unread = db.getUnreadMessages(terminal.handle) expect(unread).toHaveLength(1) expect(unread[0].read).toBe(0) - expect(unread[0].delivered_at).not.toBeNull() + expect(unread[0].delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -18591,7 +18623,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello claude')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) expect(write).toHaveBeenCalledWith('pty-1', '\r') db.close() } finally { @@ -18622,16 +18657,16 @@ describe('OrcaRuntimeService', () => { await vi.advanceTimersByTimeAsync(500) const firstInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(firstInjections).toBe(1) - // Second idle transition: the row is unread but already delivered, so push-on-idle must skip it to avoid the replay bug. + // The row remains pending, so the in-memory sequence watermark prevents replay. runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) const totalInjections = write.mock.calls.filter( - (c) => typeof c[1] === 'string' && c[1].includes('Subject: hello') + (c) => typeof c[1] === 'string' && c[1].includes('orca orchestration check') ).length expect(totalInjections).toBe(1) db.close() @@ -33284,16 +33319,124 @@ describe('OrcaRuntimeService', () => { // The push is deferred one microtask so it lands behind any resolved check. await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) + expect(write).not.toHaveBeenCalledWith('pty-1', expect.stringContaining('after wait')) await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-1', '\r') - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() } }) + it('points a Run mailbox at its live-idle coordinator without replaying pending rows', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_mailbox', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_mailbox', + subject: 'one P3 finding', + body: 'private worker report', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + expect(write).not.toHaveBeenCalled() + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(write).not.toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('private worker report') + ) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(message.delivered_at).toBeNull() + + runtime.notifyMessageArrived('run:run_mailbox', 'worker_done') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(500) + expect( + write.mock.calls.filter( + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') + ) + ).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + + it('points already-idle Run mail after Codex replaces its completion title', async () => { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => 'codex' + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + db.setRun({ + id: 'run_codex_native_title', + coordinator_handle: terminal.handle, + coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + runtime.onPtyData('pty-1', '\x1b]0;fix-12953-orchestration-mail-pointer\x07', 101) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + db.insertMessage({ + from: 'term_worker', + to: 'run:run_codex_native_title', + subject: 'real-agent smoke complete', + body: 'The package name is orca.', + type: 'worker_done' + }) + + runtime.notifyMessageArrived('run:run_codex_native_title', 'worker_done') + await Promise.resolve() + + await vi.waitFor(() => { + expect(write).toHaveBeenCalledWith( + 'pty-1', + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + db.close() + }) + it('does not inject pending mail on notify when the recipient is still working', async () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() @@ -33355,9 +33498,12 @@ describe('OrcaRuntimeService', () => { // is no transition — only the liveness edge can release the row (#12536). runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 100) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: restored idle')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33399,9 +33545,12 @@ describe('OrcaRuntimeService', () => { // The first live idle frame authorizes it and the row still delivers. runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: seeded idle')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33503,9 +33652,11 @@ describe('OrcaRuntimeService', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) - expect(payloads.some((data) => data.includes('Subject: reserved completion'))).toBe(false) - expect(status.delivered_at).not.toBeNull() + expect(payloads).toContain( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + expect(payloads.some((data) => data.includes('reserved completion'))).toBe(false) + expect(status.delivered_at).toBeNull() expect(done.delivered_at).toBeNull() db.close() } finally { @@ -33684,10 +33835,10 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: after republish') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33738,10 +33889,10 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 200) expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: after same id respawn') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33787,10 +33938,10 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: unfiltered status') + expect.stringContaining('You have 1 orchestration message') ) await vi.advanceTimersByTimeAsync(600) - expect(message.delivered_at).not.toBeNull() + expect(message.delivered_at).toBeNull() // The filtered waiter stays blocked; the push did not consume its wake. await vi.advanceTimersByTimeAsync(5_000) @@ -33865,10 +34016,10 @@ describe('OrcaRuntimeService', () => { runtime.notifyMessageArrived(terminal.handle, 'status') await Promise.resolve() - const payloadWrites = write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: once only') + const pointerWrites = write.mock.calls.filter( + ([, payload]) => typeof payload === 'string' && payload.includes('orca orchestration check') ) - expect(payloadWrites).toHaveLength(1) + expect(pointerWrites).toHaveLength(1) await vi.advanceTimersByTimeAsync(500) const enterWrites = write.mock.calls.filter(([, payload]) => payload === '\r') @@ -33910,9 +34061,10 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() expect( write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') ) - ).toHaveLength(0) + ).toHaveLength(1) expect(second.delivered_at).toBeNull() // Why: release must not require another agent-status OSC — only the @@ -33920,13 +34072,18 @@ describe('OrcaRuntimeService', () => { // timer-only settle (CodeRabbit settling-timeout gap, #12584). await vi.advanceTimersByTimeAsync(3_000) expect(write).toHaveBeenCalledWith('pty-1', '\r') - expect(first.delivered_at).not.toBeNull() + expect(first.delivered_at).toBeNull() expect( write.mock.calls.filter( - ([, payload]) => typeof payload === 'string' && payload.includes('Subject: second') + ([, payload]) => + typeof payload === 'string' && payload.includes('orca orchestration check') ) - ).toHaveLength(1) - expect(second.delivered_at).not.toBeNull() + ).toHaveLength(2) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 2 orchestration messages') + ) + expect(second.delivered_at).toBeNull() db.close() } finally { vi.useRealTimers() @@ -33953,7 +34110,10 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('You have 1 orchestration message') + ) db.close() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ea146b52380..7983ebacb40 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -134,7 +134,7 @@ import type { OrchestrationWorkerServer } from './orchestration/environment-transport' import { syncFederatedDispatch } from './orchestration/federation-sync' -import { formatMessagesForInjection } from './orchestration/formatter' +import { formatMessagePointer } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import type { Automation, @@ -2878,6 +2878,8 @@ export class OrcaRuntimeService { private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() + // Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change. + private readonly lastPointedMessageSequenceByHandle = new Map() private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] @@ -10318,7 +10320,7 @@ export class OrcaRuntimeService { // prompt) then shows no transition — the row would strand, which is // exactly #12536. Waiter semantics stay transition-only above. if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { - this.deliverPendingMessages(leaf) + this.deliverPendingMessagesForLeaf(leaf) } } return ptyRecordChanged @@ -16793,7 +16795,22 @@ export class OrcaRuntimeService { result.available && recognizeAgentProcess(result.process) !== null ) { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit() + if (restoredStatus !== null && restoredStatus !== undefined) { + current.lastAgentStatus = restoredStatus + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } return } this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) @@ -31118,25 +31135,40 @@ export class OrcaRuntimeService { } deliverPendingMessagesForHandle(handle: string, reservedTypes?: ReadonlySet): void { - // Why before the try: `dispatch:`/`run:` mailbox addresses are never terminal - // handles, and federation sync notifies once per relayed item — letting each - // one build and discard a `terminal_handle_stale` Error (stack capture) is - // pure waste. getLiveLeafForHandle would reject them on the same lookup. - if (!this.handles.has(handle)) { - return + let terminalHandle = handle + if (!this.handles.has(terminalHandle)) { + const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : '' + const coordinatorHandle = runId + ? this._orchestrationDb?.getRun(runId)?.coordinator_handle + : null + if (!coordinatorHandle || !this.handles.has(coordinatorHandle)) { + return + } + terminalHandle = coordinatorHandle } try { - const { leaf } = this.getLiveLeafForHandle(handle) + const { leaf } = this.getLiveLeafForHandle(terminalHandle) // Why lastAgentStatusObservedLive: a cold restore seeds `idle` from the // title persisted at snapshot time, so an agent that went busy across the // relaunch still reads idle until its first live frame. Pushing on that - // would type a message plus Enter into a working agent and stamp the row - // delivered. Seeded state waits for a live observation to authorize it. + // would type a message plus Enter into a working agent. Seeded state waits + // for a live observation to authorize it. if (leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive) { - this.deliverPendingMessages(leaf, false, reservedTypes) + this.deliverPendingMessages(leaf, { mailboxHandle: handle, reservedTypes }) } } catch { - // Unknown/stale handles can't be pushed now; the persisted message stays available via explicit check or future idle delivery. + // Unknown/stale handles can't be pointed now; the persisted message stays available via explicit check or future idle delivery. + } + } + + private deliverPendingMessagesForLeaf(leaf: RuntimeLeafRecord): void { + this.deliverPendingMessages(leaf) + if (!this._orchestrationDb) { + return + } + const run = this._orchestrationDb.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.deliverPendingMessages(leaf, { mailboxHandle: `run:${run.id}` }) } } @@ -31147,10 +31179,8 @@ export class OrcaRuntimeService { // deliver now (#12536). deliverPendingMessagesForHandle no-ops when the // leaf is not idle. Main's messageDeliveryFlights serialize mid-Enter // re-notifies without a separate settle barrier. - // Why skip when a waiter will consume this: deliverPendingMessages stamps - // delivered_at but not read, so a blocked orchestration.check --wait would - // still re-read the row and the pane would also receive it (double - // delivery). The pull wins; the push stays pending for a later notify. + // Why skip when a waiter will consume this: a blocked check owns the row, + // so also pointing and submitting the pane would wake it twice. The pull wins. // Why "will consume" and not "exists": a waiter filtered to other types // never returns this row — check re-reads under the same filter on timeout // — so treating it as the consumer strands the message in exactly the @@ -31767,10 +31797,8 @@ export class OrcaRuntimeService { return null } - // Why: delivered_at for Claude targets stamps only in the delayed-Enter - // callback, so the whole write→settle span must be single-flight per pty — - // a second read inside it would re-inject the same unread rows. Triggers - // landing mid-flight park the latest leaf and re-run once on settle. The + // Why: the whole pointer→Enter span must be single-flight per pty. Triggers + // landing mid-flight park their mailbox and re-run once on settle. The // flight object is the settle identity: a stale settle surviving an exit // retire must not clear a newer same-id flight or flush its parked trigger. private readonly messageDeliveryFlightsByPtyId = new Map< @@ -31778,7 +31806,10 @@ export class OrcaRuntimeService { { enterTimer: ReturnType | null } >() - private readonly parkedMessageRedeliveryLeavesByPtyId = new Map() + private readonly parkedMessageRedeliveriesByPtyId = new Map< + string, + Map }> + >() private settlePendingMessageDelivery( ptyId: string, @@ -31788,31 +31819,47 @@ export class OrcaRuntimeService { return } this.messageDeliveryFlightsByPtyId.delete(ptyId) - const parkedLeaf = this.parkedMessageRedeliveryLeavesByPtyId.get(ptyId) - if (!parkedLeaf) { + const parked = this.parkedMessageRedeliveriesByPtyId.get(ptyId) + if (!parked) { return } - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) - this.deliverPendingMessages(parkedLeaf) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const [mailboxHandle, delivery] of parked) { + this.deliverPendingMessages(delivery.leaf, { + mailboxHandle, + reservedTypes: delivery.reservedTypes + }) + } } - // Why: an Enter armed for a dead session must not fire into a same-id cold - // restore — it would inject \r and stamp rows the replacement never saw. - // Retire without stamping; the rows re-deliver on the replacement's next idle. + // Why: a dead session's Enter or watermark must not affect a same-id cold restore. private retirePendingMessageDeliveryForPty(ptyId: string): void { const flight = this.messageDeliveryFlightsByPtyId.get(ptyId) if (flight?.enterTimer != null) { clearTimeout(flight.enterTimer) } this.messageDeliveryFlightsByPtyId.delete(ptyId) - this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) + this.parkedMessageRedeliveriesByPtyId.delete(ptyId) + for (const leaf of this.getLeavesForPty(ptyId)) { + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + this.lastPointedMessageSequenceByHandle.delete(handle) + } + const run = this._orchestrationDb?.getCurrentRunForPane?.(`${leaf.tabId}:${leaf.leafId}`) + if (run) { + this.lastPointedMessageSequenceByHandle.delete(`run:${run.id}`) + } + } } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. private deliverPendingMessages( leaf: RuntimeLeafRecord, - skipAbsenceProbe = false, - reservedTypes?: ReadonlySet + options: { + mailboxHandle?: string + reservedTypes?: ReadonlySet + skipAbsenceProbe?: boolean + } = {} ): void { if (!this._orchestrationDb) { return @@ -31822,10 +31869,20 @@ export class OrcaRuntimeService { if (!handle) { return } + const mailboxHandle = options.mailboxHandle ?? handle - // Why before reading rows: rows read mid-flight are the not-yet-stamped ones. if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) { - this.parkedMessageRedeliveryLeavesByPtyId.set(leaf.ptyId, leaf) + let parked = this.parkedMessageRedeliveriesByPtyId.get(leaf.ptyId) + if (!parked) { + parked = new Map() + this.parkedMessageRedeliveriesByPtyId.set(leaf.ptyId, parked) + } + const priorReservedTypes = parked.get(mailboxHandle)?.reservedTypes + const reservedTypes = + priorReservedTypes || options.reservedTypes + ? new Set([...(priorReservedTypes ?? []), ...(options.reservedTypes ?? [])]) + : undefined + parked.set(mailboxHandle, { leaf, reservedTypes }) return } @@ -31834,12 +31891,13 @@ export class OrcaRuntimeService { // into the pane AND returned by that pull's check. Live waiters cover the // still-blocked case; reservedTypes carries the notify-time snapshot for a // waiter resolved later in the same drain, which is already gone from the map. - const waiters = this.messageWaitersByHandle.get(handle) + const waiters = this.messageWaitersByHandle.get(mailboxHandle) const unread = this._orchestrationDb - .getUndeliveredUnreadMessages(handle) + .getUndeliveredUnreadMessages(mailboxHandle) .filter( (message) => - !reservedTypes?.has(message.type) && !messageTypeHasLiveWaiter(waiters, message.type) + !options.reservedTypes?.has(message.type) && + !messageTypeHasLiveWaiter(waiters, message.type) ) if (unread.length === 0) { return @@ -31848,9 +31906,16 @@ export class OrcaRuntimeService { if (!leaf.writable || !leaf.ptyId) { return } + const newestSequence = unread.at(-1)?.sequence + if ( + newestSequence === undefined || + newestSequence <= (this.lastPointedMessageSequenceByHandle.get(mailboxHandle) ?? -1) + ) { + return + } if ( - !skipAbsenceProbe && + !options.skipAbsenceProbe && this.ptyController?.probePtyLiveness && !this.controllerKnowsPtyIsLive(leaf.ptyId) ) { @@ -31859,8 +31924,7 @@ export class OrcaRuntimeService { // them queued for a future surface; unknown liveness still delivers. const probedPtyId = leaf.ptyId // Why: triggers arriving mid-probe must not each arm a continuation — the - // Claude Enter delay stamps delivered_at late, so every continuation would - // re-read the same unread rows and double-deliver. The single armed + // Every continuation would re-read the same unread rows. The single armed // continuation re-reads fresh rows when it fires, so nothing is lost. if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) { return @@ -31882,16 +31946,18 @@ export class OrcaRuntimeService { // Why current state, not the closure: the gate that authorized this // push ran before the probe. A same-id cold restore inside the probe // window keeps ptyId identical and makes the leaf writable again, so - // an id-only check would type the payload plus Enter into a process - // whose idle was never observed — and stamp the row delivered, which - // loses it. Re-read the leaf and re-apply the live-idle gate. + // an id-only check would type the pointer plus Enter into a process + // whose idle was never observed. Re-read the live-idle gate. const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if ( currentLeaf?.ptyId === probedPtyId && currentLeaf.lastAgentStatus === 'idle' && currentLeaf.lastAgentStatusObservedLive ) { - this.deliverPendingMessages(currentLeaf, true) + this.deliverPendingMessages(currentLeaf, { + mailboxHandle, + skipAbsenceProbe: true + }) } }, 0) } @@ -31905,32 +31971,25 @@ export class OrcaRuntimeService { const deliveryPtyId = leaf.ptyId const flight: { enterTimer: ReturnType | null } = { enterTimer: null } this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight) - // Why: every sync outcome — failed write, sync-stamped branch, or a throw — + // Why: every sync outcome — failed write, Cursor branch, or a throw — // must end the flight here, or a leaked flag parks this pty's deliveries // forever. Only an armed Enter hands settling to its own callback. let settlesInEnterCallback = false try { - const payload = formatMessagesForInjection(unread) + const payload = formatMessagePointer(unread.length) const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false if (!wrote) { return } - - // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. - if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) - return - } + this.lastPointedMessageSequenceByHandle.set(mailboxHandle, newestSequence) const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control. - this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) return } - // Why: Claude Code treats a large PTY write as a paste and swallows a \r in the same write; send Enter separately after a delay, stamping delivered_at only once \r is confirmed. - // Important (design doc §3.2, feedback #2): stamp delivered_at, not read — read means "a check-caller consumed this"; flipping it would hide the message from check --unread. + // Why: agent TUIs can swallow a \r in the same PTY write; submit separately after a delay. flight.enterTimer = setTimeout(() => { try { // Why current state, not the closure: graph resync replaces leaf @@ -31943,12 +32002,9 @@ export class OrcaRuntimeService { if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - const submitted = this.ptyController?.write(deliveryPtyId, '\r') ?? false - if (submitted) { - this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) - } + this.ptyController?.write(deliveryPtyId, '\r') } catch { - // Terminal may have closed during the delay — messages stay queued (delivered_at NULL) and re-deliver on next idle. + // Terminal may have closed during the delay; mail remains queued for check. } finally { // Why finally: every outcome — submit, refusal, throw — ends the flight, // and settle re-runs any trigger parked during it so nothing strands. diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index b4732f04ac2..f7d9c14d880 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatMessageBanner, formatMessagesForInjection } from './formatter' +import { formatMessageBanner, formatMessagePointer, formatMessagesForInjection } from './formatter' import type { MessageRow } from './types' function makeMessage(overrides: Partial = {}): MessageRow { @@ -183,3 +183,15 @@ describe('formatMessagesForInjection', () => { expect(result).toContain(`${bannerA}\n\n${bannerB}`) }) }) + +describe('formatMessagePointer', () => { + it('formats a singular pointer without message content', () => { + expect(formatMessagePointer(1)).toBe( + '\nYou have 1 orchestration message. Run `orca orchestration check`.\n' + ) + }) + + it('pluralizes a batched pointer', () => { + expect(formatMessagePointer(3)).toContain('3 orchestration messages') + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 269ba29302f..678c5e08499 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -107,3 +107,8 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { const banners = messages.map(formatMessageBanner).join('\n\n') return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n` } + +export function formatMessagePointer(count: number): string { + const noun = count === 1 ? 'message' : 'messages' + return `\nYou have ${count} orchestration ${noun}. Run \`orca orchestration check\`.\n` +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 823c04e5a6d..41e272ecd29 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -267,6 +267,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { getUndeliveredUnreadMessages: (handle: string) => rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at), getActiveCoordinatorRun: () => null, + getCurrentRunForPane: () => undefined, // Consulted by onPtyExit's dispatch-failure path. getActiveDispatchForTerminal: () => null, markAsDelivered, @@ -327,7 +328,7 @@ describe('push-on-idle orchestration delivery absence gate', () => { expect(write).toHaveBeenCalledWith( STALE_PTY_ID, - expect.stringContaining('Subject: for the old session') + expect.stringContaining('You have 1 orchestration message') ) }) @@ -369,7 +370,10 @@ describe('push-on-idle orchestration delivery absence gate', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 1 orchestration message') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) expect(payloads.some((data) => data.includes('Subject: worker completion'))).toBe(false) }) @@ -411,8 +415,11 @@ describe('push-on-idle orchestration delivery absence gate', () => { const payloads = write.mock.calls .map(([, data]) => data) .filter((data): data is string => typeof data === 'string') - expect(payloads.some((data) => data.includes('Subject: unclaimed status'))).toBe(true) - expect(payloads.some((data) => data.includes('Subject: late completion'))).toBe(true) + const pointers = payloads.filter((data) => data.includes('orca orchestration check')) + expect(pointers).toHaveLength(1) + expect(pointers[0]).toContain('You have 2 orchestration messages') + expect(payloads.some((data) => data.includes('unclaimed status'))).toBe(false) + expect(payloads.some((data) => data.includes('late completion'))).toBe(false) }) it('keeps messages queued instead of marking a proven-absent pty delivered', async () => { @@ -440,12 +447,14 @@ describe('push-on-idle orchestration delivery absence gate', () => { // Why twice: the probe continuation yields a turn before delivering. await new Promise((resolve) => setTimeout(resolve, 0)) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, expect.stringContaining('Subject: hello')) + expect(write).toHaveBeenCalledWith( + STALE_PTY_ID, + expect.stringContaining('You have 1 orchestration message') + ) }) - // Why: delivered_at stamps only in the delayed-Enter callback, so the whole - // write→settle span — not just the probe — must be single-flight; a trigger - // landing inside the 500ms window would re-read the same un-stamped rows. + // Why: the whole pointer→Enter span must be single-flight; a trigger inside + // the 500ms window must park until the sequence watermark advances. it('delivers once across concurrent probe triggers and an in-window re-trigger, then flushes parked rows', async () => { vi.useFakeTimers() try { @@ -464,36 +473,33 @@ describe('push-on-idle orchestration delivery absence gate', () => { resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: exactly once') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') - // Re-trigger INSIDE the 500ms Enter window: the first batch is written but - // not yet stamped, so a fresh probe cycle would re-inject it. (On the - // fixed code no new probe is armed — the trigger parks; resolveProbe then - // re-resolves the settled first probe, a no-op.) + // Re-trigger inside the Enter window parks; resolving the settled first + // probe again is a no-op. stub.insert('second message') runtime.deliverPendingMessagesForHandle(handle) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) - // Enter fires, delivered_at stamps, the flight settles, and the parked - // trigger re-runs on its own — arming a fresh probe for the new row. + // Enter settles the flight and re-runs the parked trigger, arming a fresh + // probe for the newer sequence. await vi.advanceTimersByTimeAsync(500) resolveProbe(null) await vi.advanceTimersByTimeAsync(0) - const secondSubjectWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: second message') - ) - expect(secondSubjectWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } @@ -514,35 +520,30 @@ describe('push-on-idle orchestration delivery absence gate', () => { stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) - const firstSubjectWrites = () => + const pointerWrites = () => write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: first') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(1) + expect(pointerWrites()[0]?.[1]).toContain('You have 1 orchestration message') expect(probe).not.toHaveBeenCalled() - // Settle flushes the parked trigger; the second row delivers alone — - // its batch must not re-contain the already-stamped first row. + // Settle flushes the parked trigger; both still-pending rows are counted, + // while the newer sequence authorizes exactly one fresh pointer. await vi.advanceTimersByTimeAsync(500) - const secondOnlyWrites = write.mock.calls.filter( - ([, data]) => - typeof data === 'string' && - data.includes('Subject: second') && - !data.includes('Subject: first') - ) - expect(secondOnlyWrites).toHaveLength(1) - expect(firstSubjectWrites()).toHaveLength(1) + expect(pointerWrites()).toHaveLength(2) + expect(pointerWrites()[1]?.[1]).toContain('You have 2 orchestration messages') await vi.advanceTimersByTimeAsync(500) - expect(stub.rows.every((row) => row.delivered_at !== null)).toBe(true) + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows.every((row) => row.delivered_at === null)).toBe(true) } finally { vi.useRealTimers() } }) // Why: cold restore respawns under the SAME session id. An Enter armed for - // the dead incarnation must not fire into the replacement — it would inject - // \r and stamp rows the new session never received. + // the dead incarnation must not submit stale input into the replacement. it('retires an armed Enter when the pty exits and respawns under the same id inside the window', async () => { vi.useFakeTimers() try { @@ -569,18 +570,19 @@ describe('push-on-idle orchestration delivery absence gate', () => { runtime.deliverPendingMessagesForHandle(handle) expect( write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) ).toHaveLength(1) runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex working\x07', 200) runtime.onPtyData(STALE_PTY_ID, '\x1b]0;Codex done\x07', 201) const payloadWrites = write.mock.calls.filter( - ([, data]) => typeof data === 'string' && data.includes('Subject: for the old session') + ([, data]) => typeof data === 'string' && data.includes('orca orchestration check') ) expect(payloadWrites).toHaveLength(2) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(1) - expect(stub.rows[0].delivered_at).not.toBeNull() + expect(stub.markAsDelivered).not.toHaveBeenCalled() + expect(stub.rows[0].delivered_at).toBeNull() } finally { vi.useRealTimers() } @@ -595,18 +597,21 @@ describe('push-on-idle orchestration delivery absence gate', () => { }) const internals = runtime as unknown as { messageDeliveryFlightsByPtyId: Map - parkedMessageRedeliveryLeavesByPtyId: Map + parkedMessageRedeliveriesByPtyId: Map + lastPointedMessageSequenceByHandle: Map } stub.insert('first') runtime.deliverPendingMessagesForHandle(handle) stub.insert('second') runtime.deliverPendingMessagesForHandle(handle) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(1) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(1) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(1) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(1) runtime.onPtyExit(STALE_PTY_ID, 0) expect(internals.messageDeliveryFlightsByPtyId.size).toBe(0) - expect(internals.parkedMessageRedeliveryLeavesByPtyId.size).toBe(0) + expect(internals.parkedMessageRedeliveriesByPtyId.size).toBe(0) + expect(internals.lastPointedMessageSequenceByHandle.size).toBe(0) await vi.advanceTimersByTimeAsync(500) expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 68c9dd4633a..09ffc07a677 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -56,7 +56,7 @@ export function createAgentStatusTracker( ): { handleTitle: (title: string) => void seedTitle: (title: string) => void - restoreLastExit: () => void + restoreLastExit: () => AgentStatus | null reset: () => void } { // Why: trackers restored mid-session need a last-known status without firing @@ -92,11 +92,13 @@ export function createAgentStatusTracker( lastStatus = detectAgentStatusFromTitle(title) restorableExitStatus = null }, - restoreLastExit(): void { - if (lastStatus === null && restorableExitStatus !== null) { - lastStatus = restorableExitStatus + restoreLastExit(): AgentStatus | null { + const restoredStatus = lastStatus === null ? restorableExitStatus : null + if (restoredStatus !== null) { + lastStatus = restoredStatus } restorableExitStatus = null + return restoredStatus }, reset(): void { lastStatus = null diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 227df5bd4e3..32dc33724ba 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -5,6 +5,7 @@ */ import { + type AgentStatus, clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, @@ -91,7 +92,7 @@ export type TerminalTitleTracker = { */ seedInitialTitle: (rawTitle: string) => void /** Restore the status consumed by the latest exit candidate when process evidence disproves it. */ - restoreLastAgentExit: () => void + restoreLastAgentExit: () => AgentStatus | null /** Last title surfaced through onTitle, after normalization. */ getLastNormalizedTitle: () => string | null /** @@ -264,8 +265,8 @@ export function createTerminalTitleTracker( lastEmittedTitle = normalizeTerminalTitle(rawTitle) agentTracker?.seedTitle(rawTitle) }, - restoreLastAgentExit(): void { - agentTracker?.restoreLastExit() + restoreLastAgentExit(): AgentStatus | null { + return agentTracker?.restoreLastExit() ?? null }, getLastNormalizedTitle: () => lastEmittedTitle, setTransientFactScanningSuppressed(suppressed: boolean): void { diff --git a/tests/e2e/helpers/orchestration-mail-pane-agent.ts b/tests/e2e/helpers/orchestration-mail-pane-agent.ts index f948bfa8c25..df9d0311d8c 100644 --- a/tests/e2e/helpers/orchestration-mail-pane-agent.ts +++ b/tests/e2e/helpers/orchestration-mail-pane-agent.ts @@ -6,7 +6,7 @@ * writing into the pane's foreground process. A shell echoes rather than * records, so it can prove the gate but never the payload. This process owns * both sides — the test drives its title through a control file and it appends - * every stdin chunk to a ledger, which is what makes "the banner and the Enter + * every stdin chunk to a ledger, which is what makes "the pointer and the Enter * reached the agent" an assertion instead of an inference. * * Titles come from a polled file, not stdin, because orchestration writes to @@ -54,12 +54,12 @@ log({ event: 'start' }) // Raw mode is what every agent TUI does, and it is load-bearing here: a cooked // PTY applies ICRNL, so the synthesized Enter would arrive as \\n and be -// indistinguishable from the banner's own newlines. +// indistinguishable from the pointer's own newlines. if (process.stdin.isTTY) { process.stdin.setRawMode(true) } -// Every byte orchestration pushes lands here — banner text and Enter alike. +// Every byte orchestration pushes lands here — pointer text and Enter alike. process.stdin.on('data', (chunk) => log({ event: 'stdin', data: chunk.toString() })) process.stdin.resume() diff --git a/tests/e2e/helpers/orchestration-mail-store.ts b/tests/e2e/helpers/orchestration-mail-store.ts index 520498dad69..7232c8d6a37 100644 --- a/tests/e2e/helpers/orchestration-mail-store.ts +++ b/tests/e2e/helpers/orchestration-mail-store.ts @@ -3,9 +3,8 @@ * * Why read SQLite instead of `orchestration.check`: check is itself a consumer — * it marks rows read and backfills `delivered_at` — so using it to observe would - * destroy the very distinction these specs exist to test. The two markers are - * independent on purpose: `delivered_at` means a push typed the row into a pane, - * `read` means a pull consumed it. Only an out-of-band read can tell them apart. + * destroy the very distinction these specs exist to test. A pointer changes + * neither marker; only an out-of-band read can prove that before check consumes. */ import path from 'node:path' import Database from '../../../src/main/sqlite/sync-database' @@ -49,13 +48,10 @@ export function readMailbox(userDataDir: string, toHandle: string): MailRow[] { } /** - * Mark `handle` as the running coordinator — the state that makes push delivery - * withhold the synthesized Enter, because that prompt holds user-typed input. + * Mark `handle` as a legacy running coordinator to prove it no longer suppresses Enter. * - * Why seed the row instead of calling `orchestration.run`: that RPC also starts - * a live coordinator loop which dispatches workers on a timer, and its - * scheduling would race every assertion here. The carve-out reads nothing but - * this row. + * Why seed instead of calling `orchestration.run`: that RPC starts a coordinator + * loop whose scheduling would race the assertion. */ export function startCoordinatorRun(userDataDir: string, handle: string): void { withMailDb(userDataDir, (db) => { @@ -67,7 +63,7 @@ export function startCoordinatorRun(userDataDir: string, handle: string): void { } /** - * How a row was consumed, if at all. + * How a row was consumed under either the current or historical push behavior. * * `read` is checked first because a pull backfills `delivered_at` via COALESCE, * so a pulled row also carries a delivery stamp — the stamp alone cannot prove diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index 2d1ec41b6bf..fcfe81005f2 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -9,7 +9,7 @@ * * These specs drive real PTYs: the recipient is a fake `codex` on PATH whose OSC * titles the test controls through a file, and which appends every stdin chunk - * to a ledger. That ledger is the oracle — it proves the banner and the + * to a ledger. That ledger is the oracle — it proves the pointer and the * synthesized Enter reached the agent process, which no store or DB read can. * * The ordering fixes on this path (microtask deferral, probe-window respawn, @@ -19,6 +19,7 @@ */ import { test, expect } from './helpers/orca-app' import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' import { execInTerminal, @@ -37,16 +38,16 @@ import { } from './helpers/orchestration-mail-pane-agent' import { mailDisposition, + readMailbox, readMailRow, startCoordinatorRun } from './helpers/orchestration-mail-store' import { waitForPtyShellEcho } from './terminal-pty-readiness' -/** The wrapper `formatMessagesForInjection` puts around every pushed batch. */ -const BANNER_PREFIX = '--- Orchestration Messages' +const POINTER_COMMAND = 'orca orchestration check' // Why generous: the push runs a microtask behind the send, may defer once more -// behind a liveness probe, and only stamps delivered_at after a 500ms Enter. +// behind a liveness probe, and submits Enter after a 500ms delay. const DELIVERY_TIMEOUT_MS = 20_000 // Why 3s: long enough to cover that same chain, so "still pending" means the // gate refused rather than that the push had not run yet. @@ -175,18 +176,19 @@ async function sendMail( return sent.result.message.id } -async function expectPushed(pane: AgentPane, subject: string): Promise { +async function expectPointed(pane: AgentPane, count = 1): Promise { await expect .poll(() => pane.agent.readStdin(), { timeout: DELIVERY_TIMEOUT_MS, - message: 'banner never reached the agent process' + message: 'mail pointer never reached the agent process' }) - .toContain(BANNER_PREFIX) - expect(pane.agent.readStdin()).toContain(`Subject: ${subject}`) + .toContain(POINTER_COMMAND) + const noun = count === 1 ? 'message' : 'messages' + expect(pane.agent.readStdin()).toContain(`${count} orchestration ${noun}`) } /** - * The synthesized Enter is a separate write ~500ms after the banner. The banner + * The synthesized Enter is a separate write ~500ms after the pointer. The pointer * itself is `\n`-joined, so a `\r` anywhere in stdin can only be that submit — * which keeps the assertion independent of how the PTY chunks the two writes. */ @@ -219,7 +221,7 @@ async function expectStaysPending( expect(readMailRow(userDataDir, messageId)).toBeDefined() await page.waitForTimeout(NO_DELIVERY_SETTLE_MS) expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') - expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(pane.agent.readStdin()).not.toContain(POINTER_COMMAND) } test.describe('orchestration push-on-idle mail delivery', () => { @@ -237,13 +239,13 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Already idle delivery' const messageId = await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) + await expectPointed(pane) await expectSubmitted(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') }) test('holds mail while the agent is working and releases it on the idle frame', async ({ @@ -263,12 +265,12 @@ test.describe('orchestration push-on-idle mail delivery', () => { // Releasing the gate proves the silence above was the working status and not // a harness that never wired the send to this pane at all. pane.agent.setTitle(CODEX_IDLE_TITLE) - await expectPushed(pane, subject) + await expectPointed(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') }) // Guards the null→idle path rather than reproducing #12536: a fresh pane has @@ -291,7 +293,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { // Idle is this pane's FIRST live status, so there is no busy→idle edge here // either; delivery has to hang off the liveness of the observation. pane.agent.setTitle(CODEX_IDLE_TITLE) - await expectPushed(pane, subject) + await expectPointed(pane) await expectSubmitted(pane) }) @@ -325,7 +327,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { const pulled = await waiting expect(pulled.result.messages.map((message) => message.subject)).toContain(subject) - expect(pane.agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(pane.agent.readStdin()).not.toContain(POINTER_COMMAND) // Pending, not pushed: the pull won, and the push stays available for a // later notify rather than racing this one. expect(mailDisposition(readMailRow(userDataDir, messageId))).toBe('pending') @@ -355,16 +357,95 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Filtered waiter' const messageId = await sendMail(client, pane.handle, { subject, type: 'status' }) - await expectPushed(pane, subject) + await expectPointed(pane) await expect .poll(() => mailDisposition(readMailRow(userDataDir, messageId)), { timeout: DELIVERY_TIMEOUT_MS }) - .toBe('pushed') + .toBe('pending') await waiting }) - test('writes the banner but never Enter for the active coordinator pane', async ({ + test('worker completion points and wakes its idle Run coordinator without consuming mail', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + const { client, userDataDir, openAgentPane } = await setUpMailFixture(orcaPage, electronApp) + const pane = await openAgentPane() + await driveToLiveIdle(client, pane) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Verify worker completion pointer delivery', + from: pane.handle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report one P3 finding', + run: run.result.run.id, + callerTerminalHandle: pane.handle + }) + const dispatched = await client.call<{ dispatch: { id: string } }>('orchestration.dispatch', { + task: task.result.task.id, + run: run.result.run.id, + from: pane.handle, + to: pane.handle + }) + const body = 'full private review finding must remain in SQLite' + const payload = JSON.stringify({ + taskId: task.result.task.id, + dispatchId: dispatched.result.dispatch.id, + outcome: 'succeeded' + }) + const sendParams = { + from: pane.handle, + to: pane.handle, + subject: 'review: one P3 finding', + body, + type: 'worker_done', + payload + } + const orchestrationRequestId = randomUUID() + const sent = await client.call<{ message: { id: string; to_handle: string } }>( + 'orchestration.send', + sendParams, + { orchestrationRequestId } + ) + const runAddress = `run:${run.result.run.id}` + expect(sent.result.message.to_handle).toBe(runAddress) + + await expectPointed(pane) + await expectSubmitted(pane) + expect(pane.agent.readStdin()).not.toContain(body) + const pointedRow = readMailRow(userDataDir, sent.result.message.id) + expect(pointedRow).toMatchObject({ to_handle: runAddress, read: 0, delivered_at: null }) + + const stdinAfterFirstPointer = pane.agent.readStdin() + const duplicate = await client.call<{ message: { id: string } }>( + 'orchestration.send', + sendParams, + { orchestrationRequestId } + ) + pane.agent.setTitle(CODEX_WORKING_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) + pane.agent.setTitle(CODEX_IDLE_TITLE) + await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) + await orcaPage.waitForTimeout(NO_DELIVERY_SETTLE_MS) + expect(pane.agent.readStdin()).toBe(stdinAfterFirstPointer) + expect(duplicate.result.message.id).toBe(sent.result.message.id) + expect(readMailbox(userDataDir, runAddress).filter((row) => row.read === 0)).toEqual([ + expect.objectContaining({ id: sent.result.message.id }) + ]) + + const checked = await client.call<{ messages: { id: string; body: string }[] }>( + 'orchestration.check', + { terminal: pane.handle, run: run.result.run.id } + ) + expect(checked.result.messages).toEqual([ + expect.objectContaining({ id: sent.result.message.id, body }) + ]) + }) + + test('writes and submits the pointer for the active coordinator pane', async ({ orcaPage, electronApp }) => { @@ -374,17 +455,14 @@ test.describe('orchestration push-on-idle mail delivery', () => { await driveToLiveIdle(client, pane) startCoordinatorRun(userDataDir, pane.handle) - // The coordinator prompt is user-owned input; synthesizing Enter there would - // submit whatever the human was mid-way through typing (#7337). - const subject = 'Coordinator no-submit' + const subject = 'Coordinator pointer submit' await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) - await orcaPage.waitForTimeout(2_000) - expectNotSubmitted(pane) + await expectPointed(pane) + await expectSubmitted(pane) }) - test('writes the banner but never Enter for a Cursor agent pane', async ({ + test('writes the pointer but never Enter for a Cursor agent pane', async ({ orcaPage, electronApp }) => { @@ -399,7 +477,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { const subject = 'Cursor no-submit' await sendMail(client, pane.handle, { subject }) - await expectPushed(pane, subject) + await expectPointed(pane) await orcaPage.waitForTimeout(2_000) expectNotSubmitted(pane) }) diff --git a/tests/e2e/orchestration-idle-mail-restore.spec.ts b/tests/e2e/orchestration-idle-mail-restore.spec.ts index 6e38b9fd872..4e528614fa6 100644 --- a/tests/e2e/orchestration-idle-mail-restore.spec.ts +++ b/tests/e2e/orchestration-idle-mail-restore.spec.ts @@ -1,12 +1,12 @@ /** * Mail must survive a restart: never injected on restored state alone, always - * delivered once the agent speaks again (#12536). + * pointed once the agent speaks again (#12536). * * Push-on-idle now fires when mail arrives rather than only on a busy→idle edge, * which puts restart squarely on the delivery path — a pane comes back carrying * the title it had at snapshot time, and anything the runtime infers from that * is a memory, not an observation. Typing on it would submit into an agent that - * may be mid-turn and stamp the row delivered, losing it. + * may be mid-turn. * * Scope, stated plainly: this covers the restart path, not the * `lastAgentStatusObservedLive` gate itself. The seed only reaches leaves that @@ -39,7 +39,7 @@ import { import { mailDisposition, readMailRow } from './helpers/orchestration-mail-store' import { waitForPtyShellEcho } from './terminal-pty-readiness' -const BANNER_PREFIX = '--- Orchestration Messages' +const POINTER_COMMAND = 'orca orchestration check' const NO_DELIVERY_SETTLE_MS = 5_000 const DELIVERY_TIMEOUT_MS = 20_000 @@ -159,10 +159,10 @@ test('keeps mail pending across a restart and delivers it when the agent reports expect(readMailRow(session.userDataDir, messageId)).toBeDefined() await second.page.waitForTimeout(NO_DELIVERY_SETTLE_MS) expect(mailDisposition(readMailRow(session.userDataDir, messageId))).toBe('pending') - expect(agent.readStdin()).not.toContain(BANNER_PREFIX) + expect(agent.readStdin()).not.toContain(POINTER_COMMAND) // Re-emitting the SAME idle title changes no status — only its liveness — so - // the row moving here is delivery resuming on the agent's own signal. + // the pointer appearing here is delivery resuming on the agent's own signal. agent.setTitle(CODEX_IDLE_TITLE) await expect .poll(() => agent.titleEmitCount(), { timeout: 30_000 }) @@ -172,12 +172,8 @@ test('keeps mail pending across a restart and delivers it when the agent reports timeout: DELIVERY_TIMEOUT_MS, message: 'live idle frame never released the pending mail' }) - .toContain(BANNER_PREFIX) - await expect - .poll(() => mailDisposition(readMailRow(session.userDataDir, messageId)), { - timeout: DELIVERY_TIMEOUT_MS - }) - .toBe('pushed') + .toContain(POINTER_COMMAND) + expect(mailDisposition(readMailRow(session.userDataDir, messageId))).toBe('pending') } finally { if (firstApp) { await session.close(firstApp) From 595097b5fcac8df2b2e3b908c2d7f385c522c269 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 7 Aug 2026 11:20:51 +0530 Subject: [PATCH 74/88] fix(clipboard): stream the temp root when sweeping expired staged files (#12917) cleanupExpiredRemoteClipboardFiles read the entire OS temp root with readdir({ withFileTypes: true }) and mapped every entry into Promise.all, so the prefix filter only ran after a promise already existed per entry. The sweep is fire-and-forget from registerClipboardIpcHandlers at startup, so a large %TEMP% froze the main process before the window came up. Stream the root with opendir, skip foreign entries before allocating, and cap in-flight removals at 8. --- .../window/clipboard-ipc-handlers.test.ts | 46 ++---- .../clipboard-remote-file-cleanup.test.ts | 153 ++++++++++++++++++ src/main/window/clipboard-remote-file-copy.ts | 57 ++++--- 3 files changed, 201 insertions(+), 55 deletions(-) create mode 100644 src/main/window/clipboard-remote-file-cleanup.test.ts diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 406949ac69e..b1da7e9e226 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,7 @@ const { childStdinEndMock, resolveAuthorizedPathMock, fsMkdirMock, - fsReaddirMock, + fsOpendirMock, fsRmMock, fsWriteFileMock, fsOpenMock, @@ -46,7 +46,7 @@ const { }), resolveAuthorizedPathMock: vi.fn(), fsMkdirMock: vi.fn(), - fsReaddirMock: vi.fn(), + fsOpendirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), fsOpenMock: vi.fn(), @@ -69,7 +69,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, - readdir: fsReaddirMock, + opendir: fsOpendirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, @@ -133,7 +133,6 @@ import { registerClipboardHandlers, setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() @@ -173,10 +172,6 @@ function trackPromiseSettled(promise: Promise): () => boolean { return () => settled } -function dirent(name: string, directory = true): { name: string; isDirectory: () => boolean } { - return { name, isDirectory: () => directory } -} - function shellIdListArray(childCount: number): Buffer { const value = Buffer.alloc(4 + 4 * (childCount + 1)) value.writeUInt32LE(childCount) @@ -194,8 +189,12 @@ describe('registerClipboardHandlers', () => { resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) - fsReaddirMock.mockReset() - fsReaddirMock.mockResolvedValue([]) + fsOpendirMock.mockReset() + // Why: handler registration kicks off the expired-staging sweep; an empty temp root keeps it inert. + fsOpendirMock.mockImplementation(async () => ({ + async *[Symbol.asyncIterator]() {}, + close: vi.fn().mockResolvedValue(undefined) + })) fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() @@ -307,33 +306,6 @@ describe('registerClipboardHandlers', () => { } }) - it('sweeps expired remote clipboard staging directories', async () => { - const nowMs = 1760000000000 - fsReaddirMock.mockResolvedValue([ - dirent('orca-clipboard-file-expired'), - dirent('orca-clipboard-file-fresh'), - dirent('orca-clipboard-file-plain-file', false), - dirent('unrelated-temp') - ]) - fsStatMock.mockImplementation(async (targetPath: string) => { - if (targetPath.endsWith('expired')) { - return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } - } - if (targetPath.endsWith('fresh')) { - return { mtimeMs: nowMs - 1000 } - } - throw new Error(`unexpected stat: ${targetPath}`) - }) - - await cleanupExpiredRemoteClipboardFiles(nowMs) - - expect(fsRmMock).toHaveBeenCalledTimes(1) - expect(fsRmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { - recursive: true, - force: true - }) - }) - it('materializes remote files before writing them to the OS clipboard', async () => { const provider = { stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), diff --git a/src/main/window/clipboard-remote-file-cleanup.test.ts b/src/main/window/clipboard-remote-file-cleanup.test.ts new file mode 100644 index 00000000000..56700cc025e --- /dev/null +++ b/src/main/window/clipboard-remote-file-cleanup.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' + +const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ + opendirMock: vi.fn(), + rmMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + opendir: opendirMock, + rm: rmMock, + stat: statMock +})) +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: vi.fn() +})) +vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) + +import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' + +const TTL_MS = 60 * 60 * 1000 +const NOW_MS = 1_760_000_000_000 + +function mockTempRoot(entries: Iterable<{ name: string; isDirectory: () => boolean }>): void { + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield* entries + }, + close: vi.fn().mockResolvedValue(undefined) + }) +} + +function* orcaStagingDirs(count: number): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + yield { name: `orca-clipboard-file-expired-${index}`, isDirectory: () => true } + } +} + +function* unrelatedTempEntries( + count: number +): Generator<{ name: string; isDirectory: () => boolean }> { + for (let index = 0; index < count; index += 1) { + // Mirrors a real temp root: mostly foreign entries, both files and directories. + yield { name: `unrelated-${index}`, isDirectory: () => index % 2 === 0 } + } +} + +describe('cleanupExpiredRemoteClipboardFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + rmMock.mockResolvedValue(undefined) + statMock.mockResolvedValue({ mtimeMs: NOW_MS - TTL_MS - 1 }) + }) + + it('streams all entries with at most eight cleanups in flight', async () => { + mockTempRoot(orcaStagingDirs(257)) + let active = 0 + let peak = 0 + statMock.mockImplementation(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => setImmediate(resolve)) + active -= 1 + return { mtimeMs: NOW_MS - TTL_MS - 1 } + }) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(rmMock).toHaveBeenCalledTimes(257) + expect(peak).toBe(8) + }) + + it('does no per-entry work for foreign temp-root entries', async () => { + mockTempRoot(unrelatedTempEntries(200_000)) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) + + it('acts on owned directories while the temp root is still being enumerated', async () => { + // Regression: the sweep materialized the whole temp root and built one promise + // per entry before doing any work, so a %TEMP% with ~1.2M unrelated entries + // froze the main process at startup (#12835). Streaming means an owned + // directory is swept before enumeration reaches the end. + let sweptDuringEnumeration = false + function* enumeration(): Generator<{ name: string; isDirectory: () => boolean }> { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + for (const entry of unrelatedTempEntries(1_000)) { + sweptDuringEnumeration ||= rmMock.mock.calls.length > 0 + yield entry + } + } + mockTempRoot(enumeration()) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(sweptDuringEnumeration).toBe(true) + expect(rmMock).toHaveBeenCalledTimes(1) + }) + + it('removes only expired staging directories it owns', async () => { + function* interleaved(): Generator<{ name: string; isDirectory: () => boolean }> { + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + yield* unrelatedTempEntries(50_000) + yield { name: 'orca-clipboard-file-fresh', isDirectory: () => true } + yield { name: 'orca-clipboard-file-plain-file', isDirectory: () => false } + } + mockTempRoot(interleaved()) + statMock.mockImplementation(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('expired') ? NOW_MS - TTL_MS - 1 : NOW_MS - 1000 + })) + + await cleanupExpiredRemoteClipboardFiles(NOW_MS) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(rmMock).toHaveBeenCalledTimes(1) + expect(rmMock).toHaveBeenCalledWith(join('/tmp', 'orca-clipboard-file-expired'), { + recursive: true, + force: true + }) + }) + + it('closes the temp-root handle and still sweeps when enumeration fails midway', async () => { + const close = vi.fn().mockResolvedValue(undefined) + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { name: 'orca-clipboard-file-expired', isDirectory: () => true } + throw new Error('EIO') + }, + close + }) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(rmMock).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + }) + + it('returns quietly when the temp root cannot be opened', async () => { + opendirMock.mockRejectedValue(new Error('EACCES')) + + await expect(cleanupExpiredRemoteClipboardFiles(NOW_MS)).resolves.toBeUndefined() + + expect(statMock).not.toHaveBeenCalled() + expect(rmMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 052fce31ef3..2afc60cda30 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' -import type { Dirent } from 'node:fs' -import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import type { Dir } from 'node:fs' +import { mkdir, opendir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -17,6 +17,7 @@ type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' +const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -79,32 +80,52 @@ export async function writeRemoteFileToClipboard({ } } +// Why: the OS temp root is shared with every other program and routinely holds +// millions of unrelated entries on long-lived machines, so this startup sweep +// streams it and only ever retains work for entries it actually owns. export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { const tempRoot = app.getPath('temp') - let entries: Dirent[] + let tempRootDir: Dir try { - entries = await readdir(tempRoot, { withFileTypes: true }) + tempRootDir = await opendir(tempRoot) } catch { return } - await Promise.all( - entries.map(async (entry) => { + const pending = new Set>() + try { + for await (const entry of tempRootDir) { if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - return + continue } - const tempDir = join(tempRoot, entry.name) - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { - return - } - await rm(tempDir, { recursive: true, force: true }) - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. + const cleanup = cleanupExpiredRemoteClipboardDirectory(join(tempRoot, entry.name), nowMs) + pending.add(cleanup) + void cleanup.finally(() => pending.delete(cleanup)) + if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { + await Promise.race(pending) } - }) - ) + } + } catch { + // Why: a partial best-effort sweep beats failing startup on a temp-root read error. + } finally { + // Why: exhausting the iterator already closes the handle; closing again is harmless. + await tempRootDir.close().catch(() => undefined) + } + await Promise.all(pending) +} + +async function cleanupExpiredRemoteClipboardDirectory( + tempDir: string, + nowMs: number +): Promise { + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { + await rm(tempDir, { recursive: true, force: true }) + } + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. + } } function sanitizeLocalClipboardFilename(remoteBasename: string): string { From f057cbc85f5418088b4956ba774c62bd22e940d1 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:56:34 +0900 Subject: [PATCH 75/88] fix(serve): recognize CLI-form serve args on the Electron process (#12818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(serve): recognize CLI-form serve args on the Electron process When the binary is launched as `… serve --port …` without the CLI rewrite that injects `--serve`, normalize argv so isServeMode, headless GPU flags, and serve option parsing all engage. Preserves existing `--serve*` flag behavior for the CLI-spawned path. Fixes #12677 * fix(serve): treat only CLI subcommand position as serve Parse bare `serve` as the first positional token after flags/values so an option value named `serve` cannot enable headless mode. Addresses CodeRabbit on #12818. * fix(serve): keep CLI redirects ahead of the serve argv rewrite Rewriting argv before maybeRedirectAppImageCliLaunch replaced the `serve` positional with `--serve`, so the redirect's command-name lookup saw a port number and bailed — dropping AppImage serve launches out of the CLI path. Also translate `--port=6768` (the CLI accepts it, getServeOptions only reads the next token) and the mixed `--serve --port` form, so a security-shaped flag like `--no-pairing` can no longer read as accepted while pairing stays on. Map lookups replace `in` on object literals, which turned a stray `serve toString` positional into a function spliced onto argv. * fix(serve): close the CLI-form serve gaps found in review second-instance: shouldActivateDesktopForSecondInstance matched only `--serve`, so a duplicate ` serve --port …` — the ExecStart shape documented in docs/reference/headless-linux-server.md — promoted the live headless server to a desktop window, un-fixing #11935 on exactly the launch shape this PR legitimizes. findServeSubcommandIndex consumed a flag's value unconditionally while the rewrite consumed it only when the next token was not flag-shaped. The two could disagree and swallow the `serve` token, leaving `--serve` uninjected: #12677 again in a new shape (`--port --port serve`, `--port -- serve`). Both scans now share one definition of value consumption. ` serve --help` / `serve help` bound a network-exposed runtime server with pairing on and printed nothing; the AppImage redirect already routes those three tokens to the CLI, so refuse them here too. `--no-pairing=false` translated to `--serve-no-pairing` with the value dropped, disabling pairing for an operator who asked for the opposite. The CLI reads its serve booleans as `flags.get(name) === true`, so a boolean is now translated only in its bare form and the `=` form rides through as the CLI treats it. Tests: spec-derived parity between src/cli/specs/serve.ts and the rewrite, covering both ends of the contract (serveOrcaApp and getServeOptions); a source-text lock on the index.ts redirect/rewrite ordering, which reverted silently green before; an exhaustive self-consistency property test; and the real GUI launch argv shapes that must never enter serve mode. --------- Co-authored-by: Jinwoo-H --- config/tsconfig.cli.json | 3 + config/tsconfig.tc.web.json | 1 + src/cli/serve-electron-flag-parity.test.ts | 79 +++++++ src/main/index.ts | 45 ++-- ...serve-mode-argv-cli-redirect-order.test.ts | 64 ++++++ src/main/startup/serve-mode-argv.test.ts | 208 ++++++++++++++++++ src/main/startup/serve-mode-argv.ts | 167 ++++++++++++++ src/main/startup/single-instance-lock.test.ts | 19 ++ src/main/startup/single-instance-lock.ts | 10 +- 9 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 src/cli/serve-electron-flag-parity.test.ts create mode 100644 src/main/startup/serve-mode-argv-cli-redirect-order.test.ts create mode 100644 src/main/startup/serve-mode-argv.test.ts create mode 100644 src/main/startup/serve-mode-argv.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 3e0e59381a4..386fb6a1594 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -73,6 +73,9 @@ "../src/main/openclaude/hook-service.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", + // Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this + // project's serve spec; the module has no imports, so listing it pulls in nothing else. + "../src/main/startup/serve-mode-argv.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" ], diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 7c4bb7c359e..f576fa77dc0 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -16,6 +16,7 @@ "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts", "../src/main/startup/serve-desktop-activation.ts", + "../src/main/startup/serve-mode-argv.ts", "../src/main/startup/single-instance-lock.ts", "../src/main/startup/startup-diagnostics.ts", "../src/main/window/focus-existing-window.ts", diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts new file mode 100644 index 00000000000..1abcf84ef64 --- /dev/null +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { normalizeServeModeArgv } from '../main/startup/serve-mode-argv' +import { BOOLEAN_FLAGS, GLOBAL_FLAGS } from './args' +import { SERVE_COMMAND_SPECS } from './specs/serve' + +// Why this test lives under src/cli: the Electron rewrite duplicates the CLI's serve flag list because +// the main tsconfig cannot import the CLI project, so the parity check has to run from the CLI side. +// It fails when `orca serve` grows a flag that a direct ` serve …` launch would silently drop (#12677). + +// Global flags with no `--serve-*` counterpart: +// - help: a help launch is refused outright, not translated (see serve-mode-argv's HELP_FLAGS). +// - pairing-code / environment: nothing in src/main reads them, so they ride through untouched. +const UNTRANSLATED_GLOBAL_FLAGS = new Set(['help', 'pairing-code', 'environment']) + +const serveSpec = SERVE_COMMAND_SPECS.find((spec) => spec.path.join(' ') === 'serve') +const translatedFlags = [...new Set(serveSpec?.allowedFlags ?? [])].filter( + (flag) => !UNTRANSLATED_GLOBAL_FLAGS.has(flag) +) + +describe('serve flag parity between the CLI spec and the Electron argv rewrite', () => { + it('has a serve spec to compare against', () => { + expect(translatedFlags.length).toBeGreaterThan(0) + }) + + it.each(translatedFlags)('rewrites CLI-form --%s into the --serve-* form', (flag) => { + const takesValue = !BOOLEAN_FLAGS.has(flag) + const argv = takesValue + ? ['/AppRun', 'serve', `--${flag}`, 'value'] + : ['/AppRun', 'serve', `--${flag}`] + const expected = takesValue + ? ['/AppRun', '--serve', `--serve-${flag}`, 'value'] + : ['/AppRun', '--serve', `--serve-${flag}`] + + expect(normalizeServeModeArgv(argv)).toEqual(expected) + if (takesValue) { + // The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token. + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected) + } else { + // A boolean with an attached value is not a truthy assertion: the CLI reads these as + // `flags.get(name) === true`, so `--no-pairing=false` must not disable pairing here either. + // (`--json` is the one flag the CLI reads with `.has()`; erring toward the literal value is + // the safe direction and only changes output format.) + expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=false`])).toEqual([ + '/AppRun', + '--serve', + `--${flag}=false` + ]) + } + // Idempotent: `orca serve` spawns the app already in this shape, and the rewrite runs over it too. + expect(normalizeServeModeArgv(expected)).toEqual(expected) + }) + + it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { + // Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so + // both ends of the contract are only readable statically. Without this leg the rewrite could + // emit a name nothing reads and every behavioural assertion above would still pass. + const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') + const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const start = mainSource.indexOf('function getServeOptions(') + // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. + expect(start).toBeGreaterThanOrEqual(0) + const end = mainSource.indexOf('\n}', start) + expect(end).toBeGreaterThan(start) + const getServeOptionsBody = mainSource.slice(start, end) + + for (const flag of translatedFlags) { + expect(launchSource).toContain(`'--serve-${flag}'`) + expect(getServeOptionsBody).toContain(`'--serve-${flag}'`) + } + }) + + it('keeps the untranslated allowlist tied to real global flags', () => { + for (const flag of UNTRANSLATED_GLOBAL_FLAGS) { + expect(GLOBAL_FLAGS).toContain(flag) + } + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 790a8a2ed69..e51db18a9c6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -127,6 +127,7 @@ import { installUnhandledRejectionLogging } from './startup/main-process-error-guards' import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' +import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { readActiveGpuFallbackMarker, @@ -383,6 +384,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 +// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. +// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. +// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its +// command-name lookup finds a port number and strands the launch in an in-process serve. The +// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. +const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (packagedCliEntryRedirect.redirected) { + app.exit(packagedCliEntryRedirect.status) +} +const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (appImageCliRedirect.redirected) { + app.exit(appImageCliRedirect.status) +} +// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the +// Electron process without the CLI rewrite that injects `--serve` (#12677). +// Guarded so a normal GUI launch keeps its original argv array identity. +if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) +} const isServeMode = process.argv.includes('--serve') function updateGpuAccelerationAboutPanel(): void { @@ -416,23 +444,6 @@ const desktopActivationGate = createServeDesktopActivationGate({ }, onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) }) -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} // Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. const ENABLE_FIRST_WORK_FOLDER_RENAME = false diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts new file mode 100644 index 00000000000..3a389bb80d4 --- /dev/null +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { getAppImageCliArgs } from './appimage-cli-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' + +// Why: index.ts must run both CLI redirects before rewriting argv. Rewriting +// first replaces the `serve` positional with `--serve`, so the redirect's +// command-name lookup finds a port number instead of a command and bails — +// silently dropping AppImage serve launches out of the CLI path (#12677). + +const REDIRECT_OPTIONS = { + platform: 'linux' as const, + isPackaged: true, + commandNames: ['serve', 'status'] +} +// A mounted AppImage is the case where the runtime does export these. +const MOUNTED_APPIMAGE_ENV = { APPIMAGE: '/opt/orca/Orca.AppImage', APPDIR: '/tmp/.mount_ab12' } + +function rewriteAsIndexDoes(argv: string[]): string[] { + return argvRequestsServeMode(argv) ? normalizeServeModeArgv(argv) : argv +} + +describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { + const launchArgv = ['/opt/orca/orca-ide', '--no-sandbox', 'serve', '--port', '7777', '--json'] + + it('hands the launch argv to the CLI when the redirect runs first', () => { + expect(getAppImageCliArgs(launchArgv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual([ + 'serve', + '--port', + '7777', + '--json' + ]) + }) + + it('loses the redirect if the rewrite runs first', () => { + const rewritten = rewriteAsIndexDoes(launchArgv) + expect(rewritten).toContain('--serve') + expect(getAppImageCliArgs(rewritten, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toBeNull() + }) + + it('leaves non-serve CLI commands redirectable either way', () => { + const argv = ['/opt/orca/orca-ide', 'status'] + expect(rewriteAsIndexDoes(argv)).toEqual(argv) + expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) + }) + + // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // cases above stay green if it is reversed — nothing else would catch the regression. + it('keeps index.ts running both CLI redirects before the argv rewrite', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') + const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') + const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') + const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + + expect(packagedRedirect).toBeGreaterThanOrEqual(0) + expect(appImageRedirect).toBeGreaterThanOrEqual(0) + expect(rewrite).toBeGreaterThan(packagedRedirect) + expect(rewrite).toBeGreaterThan(appImageRedirect) + // The rewrite is pointless unless it lands before the flag it exists to inject is read. + expect(serveModeCheck).toBeGreaterThan(rewrite) + }) +}) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts new file mode 100644 index 00000000000..e82c02189d8 --- /dev/null +++ b/src/main/startup/serve-mode-argv.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' +import { + argvRequestsServeMode, + findServeSubcommandIndex, + normalizeServeModeArgv +} from './serve-mode-argv' + +describe('serve-mode-argv', () => { + it('detects --serve and bare serve subcommand', () => { + expect(argvRequestsServeMode(['orca', '--serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', 'serve'])).toBe(true) + expect(argvRequestsServeMode(['/AppRun', '--no-sandbox', 'serve', '--port', '8080'])).toBe(true) + expect(argvRequestsServeMode(['orca'])).toBe(false) + }) + + it('does not treat serve as a subcommand when it is an option value', () => { + expect(findServeSubcommandIndex(['app', '--user-data-dir', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', '--user-data-dir', 'serve'])).toBe(false) + expect(normalizeServeModeArgv(['app', '--user-data-dir', 'serve'])).toEqual([ + 'app', + '--user-data-dir', + 'serve' + ]) + // The value is skipped, not the subcommand after it. + expect(findServeSubcommandIndex(['app', '--user-data-dir', '/tmp/x', 'serve'])).toBe(3) + }) + + it('refuses a help launch instead of binding a server', () => { + // Why: `--help` is not a serve flag, so it used to be swallowed and the launch bound a + // network-exposed runtime server with pairing on. The AppImage redirect routes help to the CLI. + for (const argv of [ + ['/AppRun', 'serve', '--help'], + ['/AppRun', 'serve', '-h'], + ['/AppRun', '--help', 'serve'], + ['/AppRun', 'help', 'serve'], + // Bare `help` too — the AppImage redirect's own help set includes it. + ['/AppRun', 'serve', 'help'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + // A directory named `help` is a value, not a help request. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--project-root', 'help'])).toBe(true) + // Past an operator's terminator nothing is reinterpreted, help flags included. + expect(argvRequestsServeMode(['/AppRun', 'serve', '--', '--help'])).toBe(true) + }) + + it('does not turn `--no-pairing=false` into no-pairing', () => { + // Why: the CLI reads serve booleans as `=== true`, so `=false` leaves pairing ON. Translating + // the token dropped the value and disabled pairing instead — the inversion of the bug this + // module exists to fix. + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port', '6768', '--no-pairing=false']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '6768', '--no-pairing=false']) + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--mobile-pairing=0'])).toEqual([ + '/AppRun', + '--serve', + '--mobile-pairing=0' + ]) + }) + + it('leaves real GUI launch argv alone', () => { + // Why: a false positive here is the expensive direction — the window never opens and a runtime + // server binds instead. These are the argv shapes the desktop actually receives. + for (const argv of [ + ['/Applications/Orca.app/Contents/MacOS/Orca', '-psn_0_123456'], + ['C:\\Program Files\\Orca\\Orca.exe', '--squirrel-firstrun'], + ['C:\\Program Files\\Orca\\Orca.exe', 'orca://worktree/serve'], + ['/opt/orca/orca-ide', '/home/u/serve'], + // `--pairing-code` takes the next token, so its value is never the subcommand. + ['/opt/orca/orca-ide', '--pairing-code', 'serve'], + ['/opt/orca/orca-ide', '--environment=serve'], + ['/opt/orca/orca-ide', '--', 'serve'], + ['/opt/orca/orca-ide', 'Serve'] + ]) { + expect(argvRequestsServeMode(argv), argv.join(' ')).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + } + }) + + it('does not treat a later positional serve as the subcommand after another command', () => { + expect(findServeSubcommandIndex(['app', 'status', 'serve'])).toBe(-1) + expect(argvRequestsServeMode(['app', 'status', 'serve'])).toBe(false) + }) + + it('rewrites CLI-form serve flags into --serve* form', () => { + expect( + normalizeServeModeArgv([ + '/AppRun', + 'serve', + '--port', + '9090', + '--json', + '--pairing-address', + '0.0.0.0', + '--no-pairing' + ]) + ).toEqual([ + '/AppRun', + '--serve', + '--serve-port', + '9090', + '--serve-json', + '--serve-pairing-address', + '0.0.0.0', + '--serve-no-pairing' + ]) + }) + + it('leaves already-normalized argv unchanged', () => { + // Why every value flag: the CLI's own `orca serve` spawns the app with exactly this shape + // (serveOrcaApp), and the rewrite now runs over it too — a bad mapping would drop the port here. + const argv = [ + 'orca', + '--serve', + '--serve-json', + '--serve-port', + '6768', + '--serve-pairing-address', + '100.64.1.20', + '--serve-no-pairing', + '--serve-mobile-pairing', + '--serve-recipe-json', + '--serve-project-root', + '/srv/repo' + ] + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('splits `--flag=value` CLI form, which getServeOptions cannot read', () => { + expect( + normalizeServeModeArgv(['/AppRun', 'serve', '--port=9090', '--pairing-address=0.0.0.0']) + ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) + }) + + it('translates serve flags in the mixed `--serve --port` form', () => { + // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. + expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ + 'orca', + '--serve', + '--serve-port', + '9090', + '--serve-no-pairing' + ]) + }) + + it('leaves a non-serve launch untouched', () => { + const argv = ['orca', '--no-sandbox', '/home/u/project'] + expect(argvRequestsServeMode(argv)).toBe(false) + expect(normalizeServeModeArgv(argv)).toEqual(argv) + }) + + it('does not splice prototype members onto argv for stray positionals', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', 'toString'])).toEqual([ + '/AppRun', + '--serve', + 'toString' + ]) + }) + + it('passes args after `--` through verbatim', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--json', '--', '--port', '1'])).toEqual([ + '/AppRun', + '--serve', + '--serve-json', + '--', + '--port', + '1' + ]) + }) + + it('never disagrees with itself about whether a launch is serve mode', () => { + // Why exhaustive: index.ts asks `argvRequestsServeMode` and then reads `--serve` out of the + // rewrite. If the two halves consume values differently one can swallow the `serve` token the + // other is rewriting, and the launch boots a desktop window with every serve flag dropped — + // #12677 again. Found by fuzzing `--port --port serve` and `--port -- serve`. + const alphabet = [ + 'serve', + '--serve', + '--port', + '--json', + '--pairing-code', + '--user-data-dir', + '--', + '-h', + 'help', + 'value', + '--port=1', + '--no-pairing=false' + ] + const disagreements: string[][] = [] + const walk = (tail: string[]): void => { + const argv = ['/AppRun', ...tail] + if (argvRequestsServeMode(argv) !== normalizeServeModeArgv(argv).includes('--serve')) { + disagreements.push(argv) + } + if (tail.length === 4) { + return + } + for (const token of alphabet) { + walk([...tail, token]) + } + } + walk([]) + + expect(disagreements).toEqual([]) + }) +}) diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts new file mode 100644 index 00000000000..3ba25fd8d85 --- /dev/null +++ b/src/main/startup/serve-mode-argv.ts @@ -0,0 +1,167 @@ +/** + * Detect and normalize headless-serve argv for both Electron flags (`--serve`) + * and CLI-form subcommands (`serve --port …`) that land on the main process + * when the AppImage/CLI redirect did not rewrite them. + */ + +const SERVE_FLAG = '--serve' + +// Why Map, not a record: `'toString' in {}` is true, so an object lookup turns a +// stray `serve toString` positional into a function spliced onto argv. +const CLI_TO_SERVE_FLAG = new Map([ + ['--json', '--serve-json'], + ['--no-pairing', '--serve-no-pairing'], + ['--mobile-pairing', '--serve-mobile-pairing'], + ['--recipe-json', '--serve-recipe-json'] +]) + +const CLI_TO_SERVE_VALUE_FLAG = new Map([ + ['--port', '--serve-port'], + ['--pairing-address', '--serve-pairing-address'], + ['--project-root', '--serve-project-root'] +]) + +/** + * Flags that consume the next argv token as a value (CLI-form + Electron passthrough). + * Residual class: a flag outside this list whose space-separated value is literally `serve` would + * read as the subcommand. Chromium switches are `--flag=value` only, so no real launch does that. + */ +const VALUE_TAKING_FLAGS = new Set([ + ...CLI_TO_SERVE_VALUE_FLAG.keys(), + '--serve-port', + '--serve-pairing-address', + '--serve-project-root', + '--user-data-dir', + '--environment', + '--pairing-code' +]) + +// Why: a CLI-form `serve` is not a serve launch when help was asked for. The AppImage redirect +// already hands these to the CLI (the same three tokens); without the refusal here, +// ` serve --help` binds a network-exposed runtime server with pairing on and prints nothing. +// Scoped to the subcommand form on purpose: `--serve` is the CLI's own contract and never carries +// `--help`, so widening this would risk the one path that already works. +const HELP_FLAGS = new Set(['--help', '-h', 'help']) + +function isFlagToken(token: string | undefined): boolean { + return Boolean(token && token.startsWith('-')) +} + +/** + * Value consumption for the two scans that walk argv looking for a position: a flag takes the next + * token only when that token is not itself flag-shaped. `normalizeServeModeArgv` consumes a strict + * subset of this (only the CLI value flags it translates), which is the property + * `findServeSubcommandIndex` documents below. + */ +function indexAfterToken(argv: readonly string[], i: number): number { + return i + (VALUE_TAKING_FLAGS.has(argv[i]!) && !isFlagToken(argv[i + 1]) ? 2 : 1) +} + +function requestsHelp(argv: readonly string[]): boolean { + let i = 1 + while (i < argv.length) { + const token = argv[i]! + if (token === '--') { + return false + } + // Skipping values matters for the bare `help` token: `serve --project-root help` names a + // directory, it does not ask for help. + if (HELP_FLAGS.has(token)) { + return true + } + i = indexAfterToken(argv, i) + } + return false +} + +/** + * Index of the CLI `serve` subcommand: the first positional token after flags + * (and their values). Option *values* named `serve` are never treated as the + * subcommand. + * + * What the tokens this skips must satisfy: they are a superset of the ones `normalizeServeModeArgv` + * consumes as values. That one-directional property is what keeps the serve index off a token the + * rewrite treats as a value — if the two ever disagreed, one half would swallow the `serve` token + * the other half is rewriting and `--serve` would never be injected: #12677 again in a new shape. + * Shrinking VALUE_TAKING_FLAGS to "restore symmetry" breaks it (see `--user-data-dir serve`). + */ +export function findServeSubcommandIndex(argv: readonly string[]): number { + if (requestsHelp(argv)) { + return -1 + } + let i = 1 + while (i < argv.length) { + const token = argv[i] + if (token === '--') { + return -1 + } + if (!isFlagToken(token)) { + return token === 'serve' ? i : -1 + } + i = indexAfterToken(argv, i) + } + return -1 +} + +/** True when argv already has `--serve` or a bare `serve` CLI subcommand. */ +export function argvRequestsServeMode(argv: readonly string[]): boolean { + return argv.includes(SERVE_FLAG) || findServeSubcommandIndex(argv) !== -1 +} + +/** + * Rewrite CLI-form `serve` invocations into the `--serve*` flag shape that + * `getServeOptions` already understands. Idempotent when already in flag form. + * + * Serve flags are translated whenever serve mode is requested, including the + * mixed `--serve --port 6768` form: leaving them untranslated is what makes a + * security-shaped flag like `--no-pairing` read as accepted while pairing stays + * on (#12677). + */ +export function normalizeServeModeArgv(argv: readonly string[]): string[] { + const serveIndex = findServeSubcommandIndex(argv) + if (serveIndex === -1 && !argv.includes(SERVE_FLAG)) { + return [...argv] + } + + const next: string[] = [] + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]! + if (i === serveIndex) { + next.push(SERVE_FLAG) + continue + } + if (token === '--') { + next.push(...argv.slice(i)) + break + } + // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but + // getServeOptions only reads the next token, so `=` must be split apart. + const eq = token.indexOf('=') + const name = eq === -1 ? token : token.slice(0, eq) + // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` + // (src/cli/handlers/core.ts), so `--no-pairing=false` is NOT no-pairing there. Translating it + // anyway dropped the value and disabled pairing for an operator who wrote `=false` — the + // security-shaped inversion of #12677. Passing it through leaves pairing on, as the CLI does. + const booleanFlag = eq === -1 ? CLI_TO_SERVE_FLAG.get(name) : undefined + if (booleanFlag) { + next.push(booleanFlag) + continue + } + const valueFlag = CLI_TO_SERVE_VALUE_FLAG.get(name) + if (!valueFlag) { + next.push(token) + continue + } + if (eq !== -1) { + next.push(valueFlag, token.slice(eq + 1)) + continue + } + next.push(valueFlag) + const value = argv[i + 1] + if (value !== undefined && !isFlagToken(value)) { + next.push(value) + i += 1 + } + } + return next +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index b3058c91a66..e023d7895f5 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -85,6 +85,25 @@ describe('shouldActivateDesktopForSecondInstance', () => { expect(shouldActivateDesktopForSecondInstance(['/Applications/Orca.app/orca'])).toBe(true) }) + it('ignores a duplicate CLI-form serve launch the CLI redirect never rewrote', () => { + // Why: the documented systemd unit is ` serve --port 6768 …`; an extracted AppRun/binary + // start reaches Electron in that shape, so a flag-only check would open a window on the live server. + expect( + shouldActivateDesktopForSecondInstance([ + '/opt/orca/squashfs-root/orca-ide', + 'serve', + '--port', + '6768', + '--pairing-address', + '100.64.1.20' + ]) + ).toBe(false) + // A path argument that merely contains `serve` is still a desktop launch. + expect( + shouldActivateDesktopForSecondInstance(['/opt/orca/orca-ide', '/home/u/serve-repo']) + ).toBe(true) + }) + it('fails open when no argv is available', () => { expect(shouldActivateDesktopForSecondInstance([])).toBe(true) expect(shouldActivateDesktopForSecondInstance()).toBe(true) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 4a08fa61e73..69404b42e80 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -1,4 +1,5 @@ import type { App } from 'electron' +import { argvRequestsServeMode } from './serve-mode-argv' import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics' export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = @@ -10,13 +11,12 @@ export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = // Why: stable "another process owns this profile" contract that systemd RestartPreventExitStatus= keys off; changing it silently un-fixes #11935. export const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 -// Why: `serve` is a CLI subcommand, never Electron argv — an AppImage launched as `orca serve` exits -// at the CLI redirect before requesting the lock, and the CLI re-spawns the Electron child with `--serve`. -const SERVE_MODE_ARG = '--serve' - // Why: a duplicate `orca serve` is a supervisor artifact, not a user asking for a window; fail open when argv is unavailable. +// Why not `argv.includes('--serve')`: the documented systemd unit runs ` serve --port …`, so a +// duplicate start hands this handler CLI-form argv the CLI redirect never rewrote (#12677) — matching only +// the flag form would promote the live headless server to a desktop window, un-fixing #11935. export function shouldActivateDesktopForSecondInstance(argv: readonly string[] = []): boolean { - return !argv.includes(SERVE_MODE_ARG) + return !argvRequestsServeMode(argv) } /** From 9e4e6ddae57b254f796a44d013be6663786a461e Mon Sep 17 00:00:00 2001 From: plotarmordev Date: Fri, 7 Aug 2026 15:58:29 +0800 Subject: [PATCH 76/88] feat(native-chat): render omp transcripts (#11523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): render omp transcripts omp already ships as a first-class launchable agent with session_id resume, but its transcripts had no decoder, so native chat could not render it — the agent runs and the conversation stays a raw terminal. This adds the decoder and wires it through the same path Claude, Codex and Grok use. omp writes one envelope per line, `{ type, id, parentId, timestamp, … }`, where conversation turns are `type: 'message'` and the rest is session bookkeeping. Reasoning arrives as a `thinking` content block inside the assistant turn, so the mapping follows Claude rather than Codex: thinking becomes a text block on an assistant message, where Codex and Grok emit a separate reasoning role only because their transcripts carry dedicated reasoning records. - toolCall -> tool-call, arguments passed through as the object omp writes - toolResult -> tool role, isError preserved - developer -> system, matching the Codex non-user/non-assistant fallback - blob-handle images drop, as the Claude mapper drops an image record with neither path nor url - bookkeeping and unrecognized types skip rather than throw Session files are `_.jsonl` under a per-cwd directory, so the resolver matches the id as a base-name suffix the way Codex rollout files are matched, and honors OMP_CODING_AGENT_DIR through normalizeAgentSessionsDir so it stays consistent with the AI Vault scanner. omp records no interruption or abort event, so unlike Claude and Codex there is no NATIVE_CHAT_INTERRUPTED_STATUS_TEXT path. Verified against 94,603 lines of real omp transcripts across four sessions: 50,546 records decoded, zero malformed, zero thrown. * fix(native-chat): complete omp record coverage and gate remote transcripts Review fixes on the omp transcript decoder. omp writes several record types with no `content` field, so they decoded to zero blocks and disappeared from the chat view entirely: - `bashExecution` / `pythonExecution`: TUI `!command` runs, now a tool turn - `fileMention`: `@path` attachments, listed by path (never `files[].content`, which is an auto-read dump) - `custom_message` and legacy `custom` / `hookMessage` rows, gated on `display` the way omp's own renderer gates them Also: - `stopReason: 'aborted'` turns now surface as the interrupted row, matching the Claude and Codex decoders. An abort carrying partial content keeps it. - A cancelled command cell now reads as errored. Every omp cancel path emits `exitCode: undefined`, which JSON drops, so an `exitCode !== 0` check read a cancelled run as a clean success. - omp joins Grok in requiring a locally readable transcript. Its hook reports no transcript path, so under Model-A SSH the chat view opened against a disk this process cannot read and never loaded. Applies on mobile too, which shares the same allowlist. - The session-file walk prunes omp's per-session subagent artifact directories, matching the AI Vault scanner. It was returning a subagent transcript instead of the parent session, and cost a full recursive readdir on every resolve. * style(native-chat): apply oxfmt to the omp review fixes Mobile CI gates `oxfmt --check`; the two root files were unformatted too, just ungated there. Line wrapping only, no behavior change. --------- Co-authored-by: plotarmordev <299844489+plotarmordev@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../mobile-native-chat-eligibility.test.ts | 18 ++ .../session/mobile-native-chat-eligibility.ts | 12 +- .../native-chat/session-file-resolver.test.ts | 52 ++++ src/main/native-chat/session-file-resolver.ts | 43 +++ .../transcript-line-decoders-omp.ts | 227 ++++++++++++++ .../transcript-line-decoders.omp.test.ts | 280 ++++++++++++++++++ .../native-chat/transcript-line-decoders.ts | 1 + src/main/native-chat/transcript-reader.ts | 6 +- .../native-chat/transcript-tail-reader.ts | 6 +- .../native-chat-availability.test.ts | 15 + .../native-chat/native-chat-availability.ts | 10 +- .../lib/native-chat-initial-view-mode.test.ts | 13 + .../src/lib/native-chat-initial-view-mode.ts | 10 +- .../src/lib/native-chat-supported-agent.ts | 1 + .../lib/worktree-creation-agent-seeds.test.ts | 18 ++ .../src/lib/worktree-creation-agent-seeds.ts | 3 +- .../worktree-draft-startup-view-mode.test.ts | 69 +++++ .../lib/worktree-draft-startup-view-mode.ts | 3 +- src/shared/native-chat-agent-support.test.ts | 21 +- src/shared/native-chat-agent-support.ts | 16 +- 20 files changed, 808 insertions(+), 16 deletions(-) create mode 100644 src/main/native-chat/transcript-line-decoders-omp.ts create mode 100644 src/main/native-chat/transcript-line-decoders.omp.test.ts create mode 100644 src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 387736f065e..7daa4babea6 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -97,6 +97,24 @@ describe('resolveMobileNativeChat', () => { ).toBeNull() }) + // Why: omp's hook reports no transcript path either, so mobile can only show + // its chat when the serving host is the one holding the session file. + it('admits omp only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'omp' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'omp' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'omp' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + expect(canShowMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))).toBe( + false + ) + }) + it('returns null for a plain shell (no agent)', () => { expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() }) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 2f997c2cdc5..abda64b04ab 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,10 +1,14 @@ import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' -import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '../../../src/shared/native-chat-agent-support' // Why: native chat renders an agent's own JSONL transcript, and the host -// resolver knows these transcript layouts. Grok is additionally gated on host -// readability because Model-A SSH stores its transcript on the remote target. +// resolver knows these transcript layouts. Agents whose hook reports no +// transcript path (Grok, omp) are additionally gated on host readability, +// because Model-A SSH stores their transcript on the remote target. export function isMobileNativeChatTranscriptReadable( connectionId: string | null | undefined ): boolean { @@ -50,7 +54,7 @@ export function resolveMobileNativeChat( if (!agent || !isNativeChatSupportedAgent(agent)) { return null } - if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + if (nativeChatRequiresLocalTranscript(agent) && !nativeChatTranscriptIsLocalReadable) { return null } return { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 85cd653185b..98aa1f4abad 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -137,6 +137,58 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('matches omp transcripts by session id suffix inside the per-cwd directory', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-1.jsonl') + await writeFile(target, '{}\n') + + const resolved = await resolveSessionFilePath('omp', 'sess-omp-1', { ompSessionsDir }) + expect(resolved).toBe(target) + }) + + it('never descends into an omp session artifact dir', async () => { + // Why: a session's task-subagent transcripts sit in its same-named + // `_/` artifact dir, and a label-named child CAN end in + // `_`. Asserting the parent wins would only prove the prune on a + // filesystem that happens to enumerate the dir first, so give the id exactly + // one match — inside the artifact dir. Pruned resolves to null; descending + // finds the child, whatever order readdir returns. + const root = await makeRoot('orca-native-chat-resolve-omp-artifact-') + const ompSessionsDir = join(root, 'omp-sessions') + const cwdDir = join(ompSessionsDir, '-Users-ada-repo') + const stem = '2026-07-16T00-27-02-222Z_019fd8e2-fd56-7000-acfe-2e497adfa83c' + await mkdir(join(cwdDir, stem), { recursive: true }) + await writeFile(join(cwdDir, `${stem}.jsonl`), '{}\n') + await writeFile(join(cwdDir, stem, 'worker_sess-omp-child.jsonl'), '{}\n') + + await expect( + resolveSessionFilePath('omp', 'sess-omp-child', { ompSessionsDir }) + ).resolves.toBeNull() + // The parent transcript itself still resolves through the pruned walk. + await expect( + resolveSessionFilePath('omp', '019fd8e2-fd56-7000-acfe-2e497adfa83c', { ompSessionsDir }) + ).resolves.toBe(join(cwdDir, `${stem}.jsonl`)) + }) + + it('honors OMP_CODING_AGENT_DIR when resolving omp transcripts', async () => { + const root = await makeRoot('orca-native-chat-resolve-omp-env-') + const cwdDir = join(root, 'omp-sessions', '-Users-ada-repo') + await mkdir(cwdDir, { recursive: true }) + const target = join(cwdDir, '2026-07-16T00-27-02-222Z_sess-omp-env.jsonl') + await writeFile(target, '{}\n') + + const previous = process.env.OMP_CODING_AGENT_DIR + process.env.OMP_CODING_AGENT_DIR = join(root, 'omp-sessions') + try { + await expect(resolveSessionFilePath('omp', 'sess-omp-env')).resolves.toBe(target) + } finally { + restoreEnv('OMP_CODING_AGENT_DIR', previous) + } + }) + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { // Orca launches Codex with its own managed CODEX_HOME, so rollout files land // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 33cf8c23823..136e3ea2291 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -4,6 +4,8 @@ import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' +import { normalizeAgentSessionsDir } from '../ai-vault/session-scanner-values' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, @@ -37,6 +39,15 @@ function grokSessionsDir(): string { return resolveGrokSessionsDir(process.env, homedir()) } +/** Mirrors the AI Vault scanner so an OMP_CODING_AGENT_DIR override resolves the + * same root for both, rather than leaving native chat pointed at the default. */ +function ompSessionsDir(): string { + return normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' + ) +} + export type ResolveSessionFileOptions = { /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string @@ -45,6 +56,8 @@ export type ResolveSessionFileOptions = { codexSessionsDirs?: string[] /** Override the Grok sessions root (`~/.grok/sessions`). */ grokSessionsDir?: string + /** Override the omp sessions root (`~/.omp/agent/sessions`). */ + ompSessionsDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -94,6 +107,9 @@ export async function resolveSessionFilePath( if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } + if (transcriptAgent === 'omp') { + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir()) + } return null } @@ -143,3 +159,30 @@ async function resolveGrokSessionFile( const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) return history } + +// omp keeps one directory per working directory (`-Documents-dog-app`) with the +// transcript inside it, named `_.jsonl` — so match the +// id as a base-name suffix, the way Codex rollout files are matched, and let the +// walk cover the per-cwd subdirectories. +async function resolveOmpSessionFile( + sessionId: string, + sessionsDir: string +): Promise { + const files = await walkSessionFiles(sessionsDir, 'omp', [], { + extensions: new Set(['.jsonl']), + // Why: a session's task-subagent transcripts live in its same-named + // `_/` artifact dir, and a label-named child can still end in + // `_` — so descending would let a subagent transcript win the + // suffix match over its own parent. Prune the subtree exactly as the AI + // Vault scanner does (session-scanner-source-discovery.ts): it keeps the + // walk at one readdir per workspace dir regardless of how much the session + // delegated. Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => + depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name), + filePredicate: (path) => { + const name = basename(path, extname(path)) + return name === sessionId || name.endsWith(`_${sessionId}`) + } + }) + return files[0] ?? null +} diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts new file mode 100644 index 00000000000..60fcd736f35 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -0,0 +1,227 @@ +// omp (pi-agent) JSONL line → NativeChatMessage decoder. +// +// Conversation turns are `type: 'message'`; every other record type is session +// bookkeeping. Rendering follows file order, so `parentId` is not consulted: +// omp's file is really a tree and its own TUI renders pathTo(leaf), so a session +// rewound onto a new branch shows the abandoned turns here too. That matches the +// Claude decoder, which ignores `parentUuid` the same way; unwinding the branch +// needs a stateful decoder contract shared by every agent, not an omp-only fix. + +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatBlock, + type NativeChatMessage +} from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { toolResultOutput } from './transcript-record-blocks' + +/** + * omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/ + * developer records with text, thinking, toolCall and image content blocks, the + * content-less bash/python execution cells, plus the `type: 'custom_message'` + * rows extensions inject into the conversation. + * Session bookkeeping rows (session_init, mode_change, compaction, custom) are + * skipped, as are records of an unrecognized type. + */ +export function decodeOmpTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record || (record.type !== 'message' && record.type !== 'custom_message')) { + return null + } + const id = extractString(record.id) ?? fallbackId + const timestamp = parseTimestamp(record.timestamp) + + if (record.type === 'custom_message') { + // Why: these extension-authored turns reach the model, and omp's own + // transcript renders them — but only when `display` is set; the rest are + // extension state it never shows (CustomMessageEntry, session-entries.d.ts). + const customBlocks = record.display === true ? ompContentBlocks(record.content) : [] + return customBlocks.length === 0 + ? null + : { id, role: 'system', blocks: customBlocks, timestamp, source: 'transcript' } + } + + const message = asRecord(record.message) + if (!message) { + return null + } + const role = extractString(message.role) + + if (role === 'toolResult') { + return { + id, + role: 'tool', + blocks: [ + { + type: 'tool-result', + output: toolResultOutput(message.content), + ...(message.isError === true ? { isError: true } : {}) + } + ], + timestamp, + source: 'transcript' + } + } + + if (role === 'bashExecution' || role === 'pythonExecution') { + // Why: omp persists a TUI `!command` / python run as a content-less message + // ({role, command|code, output, exitCode}) and renders it as a command cell. + // Surface it as a tool turn so the output keeps its collapsible affordance. + return { + id, + role: 'tool', + blocks: ompExecutionBlocks(role, message), + timestamp, + source: 'transcript' + } + } + + if (role === 'fileMention') { + // Why: an `@path` attachment is another content-less record omp's transcript + // renders. List the paths only — `files[].content` is an auto-read dump that + // would bury the conversation. + const paths = ompFileMentionPaths(message.files) + return paths.length === 0 + ? null + : { + id, + role: 'system', + blocks: [{ type: 'text', text: paths.map((path) => `@${path}`).join('\n') }], + timestamp, + source: 'transcript' + } + } + + // Why: sessions written before version 3 stored extension turns as + // `type: 'message'` with role custom/hookMessage and a message-level + // `display` flag (the v3 migration rewrites hookMessage -> custom). Honor the + // same gate the `custom_message` branch does, or a resumed legacy session + // renders state omp itself hides. + if ((role === 'custom' || role === 'hookMessage') && message.display !== true) { + return null + } + const blocks = ompContentBlocks(message.content) + if (blocks.length === 0) { + // Why: omp stamps an aborted turn onto the assistant message itself + // (`stopReason: 'aborted'`), and when nothing streamed before the abort the + // content is empty — so the turn would silently vanish. Surface it as the + // same interrupted row Claude and Codex emit for their own aborts. + return role === 'assistant' && message.stopReason === 'aborted' + ? { + id, + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp, + source: 'transcript' + } + : null + } + const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system' + return { id, role: messageRole, blocks, timestamp, source: 'transcript' } +} + +/** A bash/python execution cell: the invocation, then its captured output. */ +function ompExecutionBlocks( + role: 'bashExecution' | 'pythonExecution', + message: Record +): NativeChatBlock[] { + const isBash = role === 'bashExecution' + // Why: read these raw rather than via `extractString` — it trims, and leading + // or trailing whitespace is meaningful in captured command output. + const source = isBash ? message.command : message.code + const output = message.output + // Why: every cancel/timeout path returns `{exitCode: undefined, cancelled: true}`, + // and JSON.stringify drops the undefined key, so a cancelled run carries NO + // exitCode on disk. Without the `cancelled` arm it would render as a clean + // success next to partial output; omp's own cell shows a cancelled marker. + const failed = + message.cancelled === true || (typeof message.exitCode === 'number' && message.exitCode !== 0) + return [ + { + type: 'tool-call', + name: isBash ? 'bash' : 'python', + input: typeof source === 'string' ? source : '' + }, + { + type: 'tool-result', + output: typeof output === 'string' ? output : '', + ...(failed ? { isError: true } : {}) + } + ] +} + +/** The `path` of every entry in a fileMention's `files` array. */ +function ompFileMentionPaths(files: unknown): string[] { + if (!Array.isArray(files)) { + return [] + } + const paths: string[] = [] + for (const file of files) { + const path = extractString(asRecord(file)?.path) + if (path) { + paths.push(path) + } + } + return paths +} + +/** Build the blocks for one omp content array. */ +function ompContentBlocks(content: unknown): NativeChatBlock[] { + if (typeof content === 'string') { + return content.trim() ? [{ type: 'text', text: content }] : [] + } + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of content) { + const block = ompContentBlock(asRecord(item)) + if (block) { + blocks.push(block) + } + } + return blocks +} + +/** Map one omp content entry; unknown block types yield null and are dropped. */ +function ompContentBlock(record: Record | null): NativeChatBlock | null { + if (!record) { + return null + } + switch (record.type) { + case 'text': { + const text = extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'thinking': { + const text = extractString(record.thinking) ?? extractString(record.text) + return text ? { type: 'text', text } : null + } + case 'toolCall': { + const name = extractString(record.name) ?? 'tool' + return { type: 'tool-call', name, input: record.arguments } + } + case 'image': + // Why: omp stores images as content-addressed blob handles + // (`blob:sha256:…`) rather than a path or URL, so there is nothing the + // renderer can load. Dropping the block matches how the Claude mapper + // treats an image record with neither source. + return null + default: + return null + } +} + +/** `timestampMs` yields NaN for an unparsable value; the chat model wants null. */ +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts new file mode 100644 index 00000000000..04e28dfbc22 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { decodeOmpTranscriptLine } from './transcript-line-decoders' + +const line = (record: unknown): string => JSON.stringify(record) + +const message = (role: string, content: unknown, extra: Record = {}): string => + line({ + type: 'message', + id: 'rec-1', + parentId: 'rec-0', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role, content, ...extra } + }) + +describe('decodeOmpTranscriptLine', () => { + it('skips malformed lines and non-conversation records', () => { + expect(decodeOmpTranscriptLine('not json', 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'session_init', id: 'a' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'mode_change', id: 'a' }), 'f')).toBeNull() + expect( + decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f') + ).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull() + }) + + it('decodes a user turn', () => { + const decoded = decodeOmpTranscriptLine( + message('user', [{ type: 'text', text: 'resume' }]), + 'f' + ) + expect(decoded).toEqual({ + id: 'rec-1', + role: 'user', + blocks: [{ type: 'text', text: 'resume' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + }) + + it('keeps thinking and tool calls together on a mixed assistant turn', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'thinking', thinking: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'toolCall', id: 'call-1', name: 'goal', arguments: { op: 'get' } } + ]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([ + { type: 'text', text: 'Checking the goal' }, + { type: 'text', text: 'Reading it now.' }, + { type: 'tool-call', name: 'goal', input: { op: 'get' } } + ]) + }) + + it('keeps a thinking-only assistant turn on the assistant role', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'thinking', thinking: 'Weighing two options' }]), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Weighing two options' }]) + }) + + it('passes tool arguments through unchanged', () => { + const decoded = decodeOmpTranscriptLine( + message('assistant', [ + { type: 'toolCall', name: 'goal', arguments: { op: 'get', objective: null } } + ]), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'goal', + input: { op: 'get', objective: null } + }) + }) + + it('decodes a tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'ok' }], { + toolCallId: 'call-1', + toolName: 'goal', + isError: false + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([{ type: 'tool-result', output: 'ok' }]) + }) + + it('flags an errored tool result', () => { + const decoded = decodeOmpTranscriptLine( + message('toolResult', [{ type: 'text', text: 'boom' }], { + toolCallId: 'call-2', + isError: true + }), + 'f' + ) + expect(decoded?.blocks[0]).toEqual({ type: 'tool-result', output: 'boom', isError: true }) + }) + + it('surfaces a displayed custom_message, and hides a state-only one', () => { + const custom = (display: boolean): string => + line({ + type: 'custom_message', + id: 'rec-c', + customType: 'rewind-report', + display, + content: [{ type: 'text', text: 'Investigation summary' }], + timestamp: '2026-07-16T00:27:02.222Z' + }) + expect(decodeOmpTranscriptLine(custom(true), 'f')).toEqual({ + id: 'rec-c', + role: 'system', + blocks: [{ type: 'text', text: 'Investigation summary' }], + timestamp: Date.parse('2026-07-16T00:27:02.222Z'), + source: 'transcript' + }) + expect(decodeOmpTranscriptLine(custom(false), 'f')).toBeNull() + }) + + it('accepts string content on a custom_message', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'custom_message', id: 'rec-s', display: true, content: 'peer said hi' }), + 'f' + ) + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'peer said hi' }]) + }) + + it('renders a bash execution cell as a tool turn', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-b', + timestamp: '2026-07-16T00:27:02.222Z', + message: { role: 'bashExecution', command: 'ls -a', output: '.git\n', exitCode: 0 } + }), + 'f' + ) + expect(decoded?.role).toBe('tool') + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'bash', input: 'ls -a' }, + { type: 'tool-result', output: '.git\n' } + ]) + }) + + it('flags a nonzero exit code and reads python cells from `code`', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-p', + message: { + role: 'pythonExecution', + code: 'raise SystemExit(2)', + output: 'boom', + exitCode: 2 + } + }), + 'f' + ) + expect(decoded?.blocks).toEqual([ + { type: 'tool-call', name: 'python', input: 'raise SystemExit(2)' }, + { type: 'tool-result', output: 'boom', isError: true } + ]) + }) + + // Why: omp's cancel and timeout paths both return `exitCode: undefined`, which + // JSON.stringify omits — so the record has no exitCode at all and only + // `cancelled` distinguishes it from a clean run. + it('marks a cancelled run, which carries no exitCode at all', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-c1', + message: { role: 'bashExecution', command: 'sleep 99', output: 'partial', cancelled: true } + }), + 'f' + ) + expect(decoded?.blocks[1]).toEqual({ type: 'tool-result', output: 'partial', isError: true }) + }) + + it('lists file mention paths without dumping their auto-read contents', () => { + const decoded = decodeOmpTranscriptLine( + line({ + type: 'message', + id: 'rec-f', + message: { + role: 'fileMention', + files: [ + { path: 'src/a.ts', content: 'SECRET FILE BODY' }, + { path: 'src/b.ts' }, + { image: true } + ] + } + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: '@src/a.ts\n@src/b.ts' }]) + expect(JSON.stringify(decoded)).not.toContain('SECRET FILE BODY') + }) + + // Why: pre-v3 sessions stored extension turns as `type:'message'` with role + // custom/hookMessage; the display flag gates them exactly as custom_message. + it('honors the display gate on legacy custom / hookMessage message rows', () => { + const legacy = (role: string, display: boolean): string => + line({ + type: 'message', + id: 'rec-l', + message: { role, customType: 'irc:incoming', display, content: 'peer note' } + }) + expect(decodeOmpTranscriptLine(legacy('custom', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('hookMessage', false), 'f')).toBeNull() + expect(decodeOmpTranscriptLine(legacy('custom', true), 'f')).toMatchObject({ + role: 'system', + blocks: [{ type: 'text', text: 'peer note' }] + }) + }) + + it('surfaces the developer channel as system', () => { + const decoded = decodeOmpTranscriptLine( + message('developer', [{ type: 'text', text: 'context note' }]), + 'f' + ) + expect(decoded?.role).toBe('system') + }) + + it('drops blob-handle images, which the renderer cannot load', () => { + expect( + decodeOmpTranscriptLine( + message('user', [{ type: 'image', data: 'blob:sha256:abc', mimeType: 'image/webp' }]), + 'f' + ) + ).toBeNull() + }) + + it('surfaces an aborted turn as the interrupted row, not silence', () => { + // Why: omp stamps `stopReason: 'aborted'` on the assistant message itself and + // leaves the content empty when nothing streamed, so the turn used to decode + // to null and vanish. Claude and Codex both emit this row for their aborts. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: '' }], { + stopReason: 'aborted', + errorMessage: 'Stopped before model call' + }), + 'f' + ) + expect(decoded?.role).toBe('system') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Conversation interrupted' }]) + }) + + it('keeps partial content when a turn aborted mid-stream', () => { + // omp spreads the in-flight message and stamps the abort on it, so a partial + // answer is real conversation and must not be replaced by the status row. + const decoded = decodeOmpTranscriptLine( + message('assistant', [{ type: 'text', text: 'Partial answer' }], { stopReason: 'aborted' }), + 'f' + ) + expect(decoded?.role).toBe('assistant') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'Partial answer' }]) + }) + + it('still drops an empty non-aborted assistant turn', () => { + expect( + decodeOmpTranscriptLine(message('assistant', [{ type: 'text', text: '' }]), 'f') + ).toBeNull() + }) + + it('falls back to the supplied id when the record carries none', () => { + const decoded = decodeOmpTranscriptLine( + line({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }), + 'fallback-9' + ) + expect(decoded?.id).toBe('fallback-9') + expect(decoded?.timestamp).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 67b1aaf76aa..2d84d1a5586 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -12,3 +12,4 @@ export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' +export { decodeOmpTranscriptLine } from './transcript-line-decoders-omp' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 86353604e20..8cb57fdb91d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -10,7 +10,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' @@ -55,6 +56,9 @@ export async function readNativeChatTranscript( if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } + if (transcriptAgent === 'omp') { + return { messages: await readTranscript(filePath, decodeOmpTranscriptLine) } + } return { error: `Unsupported agent for Chat UI transcript: ${agent}` } } catch (err) { // Why: ENOENT after a successful resolve is the same first-flush/rotation diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 9fb66705103..35bee906956 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -9,7 +9,8 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, - decodeGrokTranscriptLine + decodeGrokTranscriptLine, + decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' import { @@ -33,6 +34,9 @@ export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineD if (transcriptAgent === 'grok') { return decodeGrokTranscriptLine } + if (transcriptAgent === 'omp') { + return decodeOmpTranscriptLine + } return null } diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 50d19d8821d..9c77e05992a 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -90,6 +90,21 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) + // Why: omp discloses no hook transcript path either, so its session file is + // only reachable when this process can read the agent's disk. + it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => { + const forConnection = (connectionId: string | null): boolean => + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'omp', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBe(false) + expect(forConnection(null)).toBe(true) + expect(forConnection('runtime-ssh-env-1')).toBe(true) + }) + it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { expect( canToggleNativeChat({ diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3d2be38e376..11b9e5af7ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -1,6 +1,9 @@ import type { Tab, TuiAgent } from '../../../../shared/types' import type { AgentType } from '../../../../shared/agent-status-types' -import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '@/lib/native-chat-supported-agent' export { isNativeChatSupportedAgent } @@ -46,7 +49,10 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean return true } const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent - if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) { + if ( + nativeChatRequiresLocalTranscript(agent) && + input.nativeChatTranscriptIsLocalReadable !== true + ) { return false } return isNativeChatSupportedAgent(agent) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 5744c7684f1..4ef61a5cb78 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import type { Tab } from '../../../shared/types' import { decideInitialAgentTabViewMode, initialAgentTabViewModeProps @@ -70,6 +71,18 @@ describe('decideInitialAgentTabViewMode', () => { ).toBe('chat') }) + it('keeps Model-A SSH omp in the terminal view but opens it locally', () => { + const forConnection = (connectionId: string | null): Tab['viewMode'] => + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'omp', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBeUndefined() + expect(forConnection(null)).toBe('chat') + }) + it('keeps Model-A SSH Grok in the terminal view', () => { expect( decideInitialAgentTabViewMode({ diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 1d2e17cabbe..9b06a0a0d38 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -1,6 +1,9 @@ import type { GlobalSettings, Tab, TuiAgent } from '../../../shared/types' import { canMirrorLaunchDraftToNativeChat } from '@/lib/native-chat-launch-draft-mirrorability' -import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from '@/lib/native-chat-supported-agent' export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' @@ -28,7 +31,10 @@ export function decideInitialAgentTabViewMode(args: { if (!isNativeChatSupportedAgent(args.agent)) { return undefined } - if (args.agent === 'grok' && args.nativeChatTranscriptIsLocalReadable !== true) { + if ( + nativeChatRequiresLocalTranscript(args.agent) && + args.nativeChatTranscriptIsLocalReadable !== true + ) { return undefined } if ( diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index 84135bba8f4..25bac3427ad 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,4 +1,5 @@ export { isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript, NATIVE_CHAT_SUPPORTED_AGENTS } from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 43c48eef25e..9ac7e28ed25 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -157,6 +157,24 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { expect(tabViewMode('agent-tab')).toBe('chat') }) + it('still opens a local omp draft in chat, despite the local-transcript gate', () => { + // Why: omp discloses no hook transcript path, so it joins Grok in requiring a + // locally readable sessions root. This call site must therefore SUPPLY that + // readability flag for omp too — gating on Grok alone left it undefined and + // parked every omp draft in the terminal view, local workspace or not. + setTabs([{ id: 'agent-tab', launchAgent: 'omp', viewMode: 'terminal' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { ...request, agent: 'omp' as const }, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(tabViewMode('agent-tab')).toBe('chat') + }) + it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => { setTabs([], 'runtime-1') diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index 565ea421d71..295b5e5c06c 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -5,6 +5,7 @@ import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' @@ -63,7 +64,7 @@ function applyBackendSpawnedDraftViewMode(args: { agent, promptDelivery: 'draft', launchDraftText: request.launchDraftPrompt, - ...(agent === 'grok' + ...(nativeChatRequiresLocalTranscript(agent) ? { nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( getConnectionIdFromState(state, worktreeId) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts new file mode 100644 index 00000000000..360e414ff13 --- /dev/null +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { resolveBackendDraftStartup } from './worktree-draft-startup-view-mode' + +type AppState = ReturnType + +const initialSettings = useAppStore.getState().settings! +const initialRepos = useAppStore.getState().repos + +const request = { + repoId: 'repo-1', + startup: { launchCommand: 'omp' }, + launchDraftPrompt: 'https://github.com/o/r/issues/12' +} as never + +function setRepoConnection(connectionId: string | null): void { + useAppStore.setState({ + repos: [{ id: 'repo-1', path: '/repo', connectionId }] + } as unknown as Partial) +} + +function viewModeFor(agent: string): string | undefined { + const startup = resolveBackendDraftStartup({ ...(request as object), agent } as never) as + | { viewMode?: string } + | undefined + return startup?.viewMode +} + +beforeEach(() => { + useAppStore.setState({ + settings: { + ...initialSettings, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) +}) + +afterEach(() => { + useAppStore.setState({ settings: initialSettings, repos: initialRepos } as Partial) +}) + +describe('resolveBackendDraftStartup', () => { + // Why: omp discloses no hook transcript path, so it joins Grok in requiring a + // locally readable sessions root. This call site must SUPPLY that flag for omp + // too — gating on Grok alone left it undefined and parked every omp draft in + // the terminal view, local workspace or not. + it('opens a local omp draft in chat', () => { + setRepoConnection(null) + expect(viewModeFor('omp')).toBe('chat') + }) + + it('keeps a Model-A SSH omp draft in the terminal view', () => { + setRepoConnection('ssh-target-1') + expect(viewModeFor('omp')).toBe('terminal') + }) + + it('opens a runtime-owned SSH omp draft in chat, which reads the transcript locally', () => { + setRepoConnection('runtime-ssh-env-1') + expect(viewModeFor('omp')).toBe('chat') + }) + + it('preserves the same split for Grok', () => { + setRepoConnection(null) + expect(viewModeFor('grok')).toBe('chat') + setRepoConnection('ssh-target-1') + expect(viewModeFor('grok')).toBe('terminal') + }) +}) diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts index b4ca4000f62..0c11ea02c80 100644 --- a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' export function resolveBackendDraftStartup( @@ -19,7 +20,7 @@ export function resolveBackendDraftStartup( agent: request.agent, promptDelivery: 'draft', launchDraftText: request.launchDraftPrompt, - ...(request.agent === 'grok' + ...(nativeChatRequiresLocalTranscript(request.agent) ? { nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) } diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index d32764d3f53..7608f50c98c 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript, resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer } from './native-chat-agent-support' @@ -11,9 +12,10 @@ describe('resolveNativeChatTranscriptAgent', () => { expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') }) - it('passes codex and grok through and rejects everything else', () => { + it('passes codex, grok and omp through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') + expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() expect(resolveNativeChatTranscriptAgent(null)).toBeNull() expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() @@ -24,12 +26,28 @@ describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) + expect(isNativeChatSupportedAgent('omp')).toBe(true) expect(isNativeChatSupportedAgent('cursor')).toBe(false) expect(isNativeChatSupportedAgent(null)).toBe(false) expect(isNativeChatSupportedAgent(undefined)).toBe(false) }) }) +describe('nativeChatRequiresLocalTranscript', () => { + it('covers the agents whose hook discloses no transcript path', () => { + // Claude/Codex report `transcript_path`; Grok and omp report only an id, so + // native chat has to find their file on a disk this process can read. + expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) + expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) + expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) + expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false) + expect(nativeChatRequiresLocalTranscript('codex')).toBe(false) + expect(nativeChatRequiresLocalTranscript('cursor')).toBe(false) + expect(nativeChatRequiresLocalTranscript(null)).toBe(false) + expect(nativeChatRequiresLocalTranscript(undefined)).toBe(false) + }) +}) + describe('shouldStepNativeChatAskAnswer', () => { it('steps the digit-commit selector agents (Claude, OpenClaude, Codex)', () => { expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) @@ -41,6 +59,7 @@ describe('shouldStepNativeChatAskAnswer', () => { it('does not step other or unknown agents', () => { expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) + expect(shouldStepNativeChatAskAnswer('omp')).toBe(false) expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false) expect(shouldStepNativeChatAskAnswer(null)).toBe(false) expect(shouldStepNativeChatAskAnswer(undefined)).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 2e063d6aa73..8210e907e0a 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,17 +1,27 @@ -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' /** Agents whose transcripts the native chat view can parse and render. */ export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ 'claude', 'openclaude', 'codex', - 'grok' + 'grok', + 'omp' ]) export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) } +/** Agents whose hook discloses no transcript path (`extractAgentProviderSession`), + * so native chat can only reach the session file by scanning a sessions root on + * a disk THIS process can read. Under Model-A SSH that disk is the wrong host, + * so the chat view must stay closed instead of loading forever. */ +export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + return transcriptAgent === 'grok' || transcriptAgent === 'omp' +} + /** True when the agent renders a digit-commit question selector that ignores * typed label text (pasting "Blue" + Enter commits the highlighted FIRST * option — STA-1860): Claude's AskUserQuestion and Codex 0.145's @@ -30,7 +40,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'codex' || agent === 'grok') { + if (agent === 'codex' || agent === 'grok' || agent === 'omp') { return agent } return null From ddf58d6d6a60e8fb4e34f96f4414dfd171dc1388 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:25:18 -0700 Subject: [PATCH 77/88] fix(terminal): restore preserved remote PTYs after host relaunch (#12990) * fix(terminal): foreground preserved daemon PTYs * fix(terminal): keep snapshot sequence domains distinct * test(terminal): use active reconnect control * test(terminal): await reconnect control activation * test(terminal): validate reconnect with fresh control * test(terminal): tighten host restart evidence * fix(terminal): retry preserved PTY attach after inventory * fix(terminal): retry attach after overlapping inventory --------- Co-authored-by: Jinwoo-H --- config/reliability-gates.jsonc | 84 +++- src/main/ipc/pty.test.ts | 47 ++ src/main/ipc/pty.ts | 2 +- src/main/runtime/orca-runtime.ts | 33 +- src/main/runtime/rpc/methods/terminal.ts | 9 +- .../runtime/rpc/terminal-multiplex.test.ts | 52 ++ .../rpc/terminal-subscribe-buffer.test.ts | 12 +- ...al-subscriber-driven-daemon-attach.test.ts | 65 +++ ...minal-host-restart-background-sync.spec.ts | 447 ++++++++++++++++++ 9 files changed, 728 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 33945cfe8c2..f7935dbfc1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4196,23 +4196,28 @@ "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", + "paired host relaunch with preserved daemon PTYs", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -4228,6 +4233,8 @@ "src/main/ipc/runtime-environments.test.ts", "src/main/runtime/rpc/terminal-multiplex.test.ts", "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "src/main/ipc/pty.test.ts", "src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts", "src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", @@ -4248,9 +4255,38 @@ "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", - "tests/e2e/terminal-parked-memory.spec.ts" + "tests/e2e/terminal-parked-memory.spec.ts", + "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "assertions": ["retries an existing subscriber when provider inventory becomes ready"] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "foregrounds a preserved daemon PTY after handler recreation loses sync memory exactly once" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-multiplex.test.ts", + "assertions": ["keeps layout versions out of the output sequence domain"] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "omits an unknown snapshot output sequence instead of publishing the layout version" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts", + "assertions": [ + "preserves daemon PID and target PTY identity across the paired host relaunch", + "foregrounds the daemon-backgrounded target on its first post-relaunch remote observation", + "delivers target and post-relaunch control terminal input and live output" + ] + }, { "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] @@ -4379,6 +4415,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", + "result": "passed", + "durationSeconds": 4.29, + "summary": "All 10 subscriber-driven attach contracts passed. With two authoritative inventories overlapping one refused in-flight attach, removing only the post-settlement retry made the byte-identical focused assertion fail with one attach instead of two; restoring it retained one bounded waiter, retried once, and resumed live output on the existing stream." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 5.84, + "summary": "The full PTY IPC file passed 449 tests, including one first-observation foreground sync after handler recreation and deduplication of the repeated presence signal." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=10 --max-failures=1", + "result": "passed", + "durationSeconds": 126, + "summary": "Ten consecutive isolated host-relaunch journeys preserved the daemon and PTY identities and resumed terminal input plus live output after the paired transport recovered." + }, + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 12.5, + "summary": "With the parking override captured by both host launches, the isolated headed host and separate paired desktop client retained the daemon PID and PTY ID across host relaunch, emitted one target foreground transition, and delivered target plus post-relaunch control input/output. The byte-identical run with only the unknown-state fix reverted failed at the missing target foreground transition." + }, { "date": "2026-08-02", "runner": "local", @@ -4503,15 +4575,15 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "The paired headed oracle is deterministic locally but has no CI or soak history yet." + "evidence": "The host-relaunch oracle passed a fresh 10-run local series. One earlier exploratory repeat timed out in transport recovery before terminal input/output assertions; it did not recur with forwarded app logs. A later Linux CI run reproduced missing live output when inventory overlapped the refused in-flight attach, which now has a deterministic unit oracle and is pending cross-platform rerun. Broader headed/headless parking coverage has no aggregated CI or soak history yet." }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 877d7d8862c..678cc877465 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -637,6 +637,7 @@ describe('registerPtyHandlers', () => { const write = vi.fn() const pauseProducer = vi.fn() const resumeProducer = vi.fn() + const setPtyBackgrounded = vi.fn() const shutdown = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null @@ -650,6 +651,7 @@ describe('registerPtyHandlers', () => { resize: vi.fn(), pauseProducer, resumeProducer, + setPtyBackgrounded, kill: vi.fn(), shutdown, sendSignal: vi.fn(), @@ -688,6 +690,7 @@ describe('registerPtyHandlers', () => { write, pauseProducer, resumeProducer, + setPtyBackgrounded, shutdown, getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), @@ -15608,6 +15611,50 @@ describe('registerPtyHandlers', () => { }) describe('hidden renderer delivery gate', () => { + it('foregrounds a preserved daemon PTY after handler recreation loses sync memory', async () => { + const daemon = installObservableDaemonTestProvider() + const firstRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, firstRuntime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + + getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: false }) + expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, true) + + daemon.setPtyBackgrounded.mockClear() + handlers.clear() + let rawSubscriberPresent = false + const nextRuntime = { + setPtyController: vi.fn(), + hasRawTerminalViewSubscriber: vi.fn(() => rawSubscriberPresent), + onRemoteTerminalViewPresenceChanged: null as ((id: string) => void) | null, + registerRawTerminalViewSubscriber(id: string): void { + rawSubscriberPresent = true + this.onRemoteTerminalViewPresenceChanged?.(id) + } + } + registerPtyHandlers(mainWindow as never, nextRuntime as never) + + nextRuntime.registerRawTerminalViewSubscriber(result.id) + // Repeated presence signals must remain deduplicated. + nextRuntime.onRemoteTerminalViewPresenceChanged?.(result.id) + + expect(daemon.setPtyBackgrounded).toHaveBeenCalledOnce() + expect(daemon.setPtyBackgrounded).toHaveBeenCalledWith(result.id, false) + }) + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { vi.useFakeTimers() const runtime = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5d858cac7f3..46498fdf0a3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2516,7 +2516,7 @@ export function registerPtyHandlers( function syncPtyBackgroundedDelivery(id: string, caller: string): void { const background = rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + if (backgroundedDeliverySyncByPty.get(id) === background) { return } const provider = tryGetProviderForPty(id) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7983ebacb40..72bd2b0c12f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3111,6 +3111,7 @@ export class OrcaRuntimeService { // concurrent first-subscribes and keeps later subscribes no-ops; it is // cleared per lifecycle generation (exit/respawn) and on failed attempts. private subscriberDrivenProviderAttachesByPtyId = new Map>() + private subscriberDrivenProviderAttachInventoryWaiters = new Set() // Why: a spawn through this app already attaches its provider stream, so // subscriber-driven attach and the never-attached read fallback must target // only inventory-discovered sessions no local spawn published this @@ -10597,6 +10598,7 @@ export class OrcaRuntimeService { this.legacyWorkerRecoveredPtys.delete(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) this.spawnPublishedPtys.delete(ptyId) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. @@ -10806,10 +10808,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -10846,6 +10845,31 @@ export class OrcaRuntimeService { }) } + private reconcileSubscriberDrivenProviderAttach(ptyId: string): void { + if (!this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + const pending = this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) + if (!pending) { + this.ensureSubscriberDrivenProviderAttach(ptyId) + return + } + if (this.subscriberDrivenProviderAttachInventoryWaiters.has(ptyId)) { + return + } + this.subscriberDrivenProviderAttachInventoryWaiters.add(ptyId) + void pending.then((attached) => { + this.subscriberDrivenProviderAttachInventoryWaiters.delete(ptyId) + if (attached || !this.hasRemoteTerminalViewSubscriber(ptyId)) { + return + } + if (this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === pending) { + this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) + } + this.ensureSubscriberDrivenProviderAttach(ptyId) + }) + } + /** Mark a raw-output viewer without transferring terminal query authority. */ registerRawTerminalViewSubscriber(ptyId: string): () => void { this.rawTerminalViewSubscriberCounts.set( @@ -29268,6 +29292,7 @@ export class OrcaRuntimeService { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null + this.reconcileSubscriberDrivenProviderAttach(session.id) } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 05f94f2e631..1cdec021e55 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2623,7 +2623,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq + // Why: layout versions and output offsets are different sequence domains. const snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -2664,7 +2664,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, @@ -3424,8 +3424,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const displayMode = runtime.getMobileDisplayMode(ptyId) // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. const layoutSeq = runtime.getLayout(ptyId)?.seq - const snapshotFrameSeq = serialized?.seq ?? layoutSeq - // Why: track the seq that actually covered the buffered chunks (recovery snapshots advance it) or an absorbed query gets zero replies. + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. let snapshotOutputSeq = serialized?.seq emit({ type: 'subscribed', @@ -3445,7 +3444,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, displayMode, - seq: snapshotFrameSeq, + seq: snapshotOutputSeq, cwd: serialized?.cwd, truncated: initialOutputOverflowed, truncatedByByteBudget: serialized?.truncatedByByteBudget, diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index dd46b36650e..95ad62ec18c 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -444,6 +444,58 @@ function startSourceRangeOverflowHarness(options: { } describe('terminal multiplex RPC', () => { + it('keeps layout versions out of the output sequence domain', async () => { + let dataListener: ((data: string, meta?: RuntimeTerminalDataMeta) => void) | undefined + const harness = startDesktopMultiplexSubscribe({ + getLayout: vi.fn().mockReturnValue({ seq: 675 }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'restored terminal', + cols: 120, + rows: 40, + source: 'renderer' + }), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed') + const snapshotStart = harness.binaryFrames + .map(decodeTerminalStreamFrame) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) + expect(subscribed.seq).toBe(675) + if (!snapshotStart) { + throw new Error('Missing multiplex snapshot start frame') + } + const snapshotPayload = decodeTerminalStreamJson(snapshotStart.payload) + expect(snapshotPayload).toMatchObject({ kind: 'scrollback' }) + expect(snapshotPayload).not.toHaveProperty('seq') + + harness.binaryFrames.splice(0) + dataListener?.('live', { seq: 4, rawLength: 4 }) + await vi.waitFor(() => + expect( + harness.binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return frame?.opcode === TerminalStreamOpcode.Output && frame.seq === 4 + }) + ).toBe(true) + ) + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + }) + it.each(['headless', 'renderer'] as const)( 'commits a source-range replacement only after the %s snapshot publishes', async (source) => { diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 837e49eff5a..8265e713fb3 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -833,15 +833,13 @@ describe('terminal subscribe buffering', () => { const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .filter((frame): frame is NonNullable => frame !== null) - const snapshotStarts = decodedFrames.filter( + const snapshotStart = decodedFrames.find( (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart ) - // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the - // layout seq on the wire; the recovered data still ships as the first - // and only scrollback snapshot. - expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ - expect.objectContaining({ kind: 'scrollback', seq: 1 }) - ]) + // Why: layout versions and output offsets are different sequence domains. + const snapshotInfo = decodeTerminalStreamJson(snapshotStart!.payload) + expect(snapshotInfo).toMatchObject({ kind: 'scrollback' }) + expect(snapshotInfo).not.toHaveProperty('seq') const snapshotText = decodedFrames .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) .map((frame) => decodeTerminalStreamText(frame.payload)) diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 7fd6c42b5fb..09c39f20137 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -37,6 +37,12 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map + subscriberDrivenProviderAttachesByPtyId: Map> + subscriberDrivenProviderAttachInventoryWaiters: Set + refreshPtyWorktreeRecordsWithControllerInventory: ( + worktrees: [], + targetWorktreeId: string | null + ) => Promise } function internals(runtime: OrcaRuntimeService): RuntimeInternals { @@ -56,11 +62,21 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { const sessions = new Map() const attachCalls: string[] = [] const resizeCalls: [string, number, number][] = [] + let nextAttachBarrier: { promise: Promise; result: boolean } | null = null let runtime: OrcaRuntimeService | null = null const controller = { write: () => true, kill: () => true, getForegroundProcess: async () => null, + listProcesses: async () => + [...sessions.entries()].map(([id, session]) => ({ + id, + cwd: '/tmp/wt', + worktreeId: WORKTREE_ID, + title: '', + cols: session.cols, + rows: session.rows + })), hasRendererSerializer: () => false, getSize: (ptyId: string) => { const session = sessions.get(ptyId) @@ -72,6 +88,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { }, attach: async (ptyId: string) => { attachCalls.push(ptyId) + const barrier = nextAttachBarrier + nextAttachBarrier = null + if (barrier) { + await barrier.promise + if (!barrier.result) { + return false + } + } const session = sessions.get(ptyId) // Attach-only: an absent session is refused, never created. if (!session) { @@ -100,6 +124,14 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) { sessions, attachCalls, resizeCalls, + deferNextAttach(result: boolean): () => void { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + nextAttachBarrier = { promise, result } + return release + }, bind(target: OrcaRuntimeService) { runtime = target }, @@ -376,6 +408,39 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { await vi.waitFor(() => expect(outputText(harness)).toContain('late daemon hello')) }) + it('retries an existing subscriber when provider inventory becomes ready', async () => { + const { runtime, model, handle } = setupNeverAttachedDaemonSession({ + snapshotCapable: false + }) + model.sessions.delete(PTY_ID) + const refuseFirstAttach = model.deferNextAttach(false) + const harness = startMultiplex(runtime) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + sendSubscribe(harness, 1, handle) + await waitForSubscribed(harness, 1) + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID])) + expect(internals(runtime).subscriberDrivenProviderAttachesByPtyId.has(PTY_ID)).toBe(true) + expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true) + expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false) + + model.sessions.set(PTY_ID, { cols: 100, rows: 30, attached: false, screen: '' }) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + expect(model.attachCalls).toEqual([PTY_ID]) + expect([...internals(runtime).subscriberDrivenProviderAttachInventoryWaiters]).toEqual([PTY_ID]) + refuseFirstAttach() + + await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])) + await vi.waitFor(() => + expect(internals(runtime).subscriberDrivenProviderAttachInventoryWaiters.has(PTY_ID)).toBe( + false + ) + ) + expect(model.emitData(PTY_ID, 'after inventory\r\n')).toBe(true) + await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory')) + }) + it('does not subscriber-attach or provider-read a session this app already spawned', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: true, diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts new file mode 100644 index 00000000000..2d0eb5a9acd --- /dev/null +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -0,0 +1,447 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + toWebTerminalSurfaceTabId +} from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { waitForTabParked } from './helpers/terminal-hidden-parking' + +const PARK_DELAY_MS = 2_000 +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-restart-background-')) +const fixturePath = path.join(scratch, 'paired-host-restart-terminal.mjs') +const backlogPath = path.join(scratch, 'daemon-stream-backlog.jsonl') + +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const sink = process.argv[2]', + "process.stdout.write('READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const lines = pending.split(/\\r\\n|\\r|\\n/)', + " pending = lines.pop() ?? ''", + ' for (const line of lines) {', + ' appendFileSync(sink, `${line}\\n`)', + ' process.stdout.write(`LIVE:${line}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(sinkPath: string): string { + const command = [process.execPath, fixturePath, sinkPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +function readDaemonPid(userDataDir: string): number { + const value = JSON.parse( + readFileSync(path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), 'utf8') + ) as { pid?: unknown } + if (typeof value.pid !== 'number') { + throw new Error('Daemon pid file did not contain a numeric pid') + } + return value.pid +} + +type BacklogEntry = { + atMs?: number + background?: boolean + backgroundedSessionIdSuffixes?: string[] + event?: string + sessionIdSuffix?: string +} + +function readBacklogEntries(): BacklogEntry[] { + try { + return readFileSync(backlogPath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as BacklogEntry] + } catch { + return [] + } + }) + } catch { + return [] + } +} + +function readText(filePath: string): string { + try { + return readFileSync(filePath, 'utf8') + } catch { + return '' + } +} + +async function callRuntime( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ environmentId, method, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method, + params + }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { environmentId, method, params } + ) as Promise +} + +type HostTerminal = { + handle: string + parentTabId: string + ptyId: string + sinkPath: string + webTabId: string +} + +async function createHostTerminal( + client: PairedElectronClient, + worktreeId: string, + name: string +): Promise { + const sinkPath = path.join(scratch, `${name}.log`) + const created = await callRuntime<{ + tab: { id: string; parentTabId: string; terminal: string | null } + }>(client.page, client.environmentId, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixtureCommand(sinkPath), + activate: false, + select: false, + navigation: 'caller' + }) + if (!created.tab.terminal) { + throw new Error('Host did not publish the fixture terminal') + } + const shown = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: created.tab.terminal } + ) + if (!shown.terminal.ptyId) { + throw new Error('Host fixture terminal has no PTY') + } + const parentTabId = + created.tab.parentTabId || created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0] + return { + handle: created.tab.terminal, + parentTabId, + ptyId: shown.terminal.ptyId, + sinkPath, + webTabId: toWebTerminalSurfaceTabId(parentTabId) + } +} + +async function findTerminalHandle( + client: PairedElectronClient, + worktreeId: string, + parentTabId: string +): Promise { + let terminal: string | null = null + await expect + .poll( + async () => { + const snapshot = await callRuntime<{ + tabs: { type: string; parentTabId?: string; terminal?: string | null }[] + }>(client.page, client.environmentId, 'session.tabs.list', { + worktree: `id:${worktreeId}` + }) + terminal = + snapshot.tabs.find((tab) => tab.type === 'terminal' && tab.parentTabId === parentTabId) + ?.terminal ?? null + return terminal + }, + { timeout: 30_000, message: `Host did not republish terminal tab ${parentTabId}` } + ) + .not.toBeNull() + if (!terminal) { + throw new Error(`Host did not republish terminal tab ${parentTabId}`) + } + return terminal +} + +async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate( + ({ worktreeId, webTabId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === webTabId + ), + { worktreeId, webTabId } + ), + { timeout: 60_000, message: `Client never mirrored host tab ${webTabId}` } + ) + .toBe(true) + await page.evaluate( + ({ worktreeId, webTabId }) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(webTabId) + state?.setActiveTabType('terminal') + }, + { worktreeId, webTabId } + ) + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), { + timeout: 60_000, + message: `Client pane for ${webTabId} did not mount` + }) + .toBe(true) +} + +async function readPaneContent(page: Page, webTabId: string): Promise { + return page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.serializeAddon?.serialize?.() ?? '' + }, webTabId) +} + +async function waitForPaneConnected(page: Page, webTabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container.dataset.ptyRecoveryState ?? null + }, webTabId), + { timeout: 30_000, message: `Pane ${webTabId} never completed transport recovery` } + ) + .toBe('connected') +} + +async function expectTerminalInteractive( + client: PairedElectronClient, + target: HostTerminal, + marker: string +): Promise { + await client.page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`No pane mounted for ${id}`) + } + pane.terminal.focus() + const textarea = pane.container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement + textarea.focus() + }, target.webTabId) + await client.page.keyboard.type(marker) + await client.page.keyboard.press('Enter') + await expect.poll(() => readText(target.sinkPath), { timeout: 15_000 }).toContain(marker) + await expect + .poll(() => readPaneContent(client.page, target.webTabId), { timeout: 15_000 }) + .toContain(`LIVE:${marker}`) +} + +async function moveHostAwayFromWorktree(page: Page, targetWorktreeId: string): Promise { + const alternateWorktreeId = await page.evaluate((targetId) => { + const state = window.__store?.getState() + const alternate = state?.allWorktrees().find((worktree) => worktree.id !== targetId) + if (!state || !alternate) { + return null + } + state.setActiveView('editor') + state.setActiveWorktree(alternate.id) + return alternate.id + }, targetWorktreeId) + if (!alternateWorktreeId) { + throw new Error('Host fixture needs a second worktree for inactive-workspace restart coverage') + } + await expect + .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null)) + .toBe(alternateWorktreeId) + return alternateWorktreeId +} + +test('foregrounds a preserved daemon PTY after the paired host relaunches', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both host launches. +{}, testInfo) => { + test.setTimeout(360_000) + const repoPath = seededRepoPathOrSkip() + writeFileSync(backlogPath, '') + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const session = createRestartSession(testInfo, { + ORCA_DAEMON_STREAM_BACKLOG_FILE: backlogPath + }) + let firstHost: ElectronApplication | null = null + let secondHost: ElectronApplication | null = null + let client: PairedElectronClient | null = null + const terminals: HostTerminal[] = [] + try { + const first = await session.launch() + firstHost = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(first.page), + testInfo, + 'host-restart-background-sync' + ) + await expect + .poll( + () => + client!.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'Paired client never saw the host worktree' } + ) + .toBe(true) + + const target = await createHostTerminal(client, worktreeId, 'target') + const firstDecoy = await createHostTerminal(client, worktreeId, 'first-decoy') + const parkingDecoy = await createHostTerminal(client, worktreeId, 'parking-decoy') + terminals.push(target, firstDecoy, parkingDecoy) + await openClientTab(client.page, worktreeId, target.webTabId) + await expect + .poll(() => readPaneContent(client!.page, target.webTabId), { timeout: 30_000 }) + .toContain('READY') + await openClientTab(client.page, worktreeId, firstDecoy.webTabId) + await openClientTab(client.page, worktreeId, parkingDecoy.webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + + const targetSuffix = target.ptyId.slice(-10) + await expect + .poll( + () => + readBacklogEntries() + .findLast((entry) => entry.backgroundedSessionIdSuffixes) + ?.backgroundedSessionIdSuffixes?.includes(targetSuffix) ?? false, + { timeout: 15_000, message: 'Daemon never retained the target background hint' } + ) + .toBe(true) + + const alternateHostWorktreeId = await moveHostAwayFromWorktree(first.page, worktreeId) + await session.close(firstHost) + firstHost = null + const foregroundEventStartMs = Date.now() + const second = await session.launch() + secondHost = second.app + await second.page.waitForFunction( + (expectedId) => + window.__store?.getState().workspaceSessionReady === true && + window.__store?.getState().activeWorktreeId === expectedId, + alternateHostWorktreeId, + { timeout: 30_000 } + ) + expect(readDaemonPid(session.userDataDir), 'daemon must survive the host relaunch').toBe( + daemonPid + ) + await expect + .poll( + () => + client!.page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.connect({ selector }) + return response.ok + }, client!.environmentId), + { timeout: 60_000, message: 'Paired client never reconnected to the relaunched host' } + ) + .toBe(true) + + target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) + const shown = await callRuntime<{ terminal: { ptyId: string | null } }>( + client.page, + client.environmentId, + 'terminal.show', + { terminal: target.handle } + ) + expect(shown.terminal.ptyId).toBe(target.ptyId) + await openClientTab(client.page, worktreeId, target.webTabId) + await waitForPaneConnected(client.page, target.webTabId) + await expect + .poll( + () => + readBacklogEntries().some( + (entry) => + (entry.atMs ?? 0) >= foregroundEventStartMs && + entry.event === 'setSessionBackground' && + entry.sessionIdSuffix === targetSuffix && + entry.background === false + ), + { timeout: 15_000, message: 'Relaunched host never foregrounded the preserved PTY' } + ) + .toBe(true) + await expectTerminalInteractive(client, target, 'x') + + const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') + terminals.push(reconnectControl) + expect(reconnectControl.ptyId).not.toBe(target.ptyId) + await openClientTab(client.page, worktreeId, reconnectControl.webTabId) + await waitForPaneConnected(client.page, reconnectControl.webTabId) + await expectTerminalInteractive(client, reconnectControl, 'y') + } finally { + if (client) { + for (const terminal of terminals) { + await callRuntime(client.page, client.environmentId, 'terminal.closeTab', { + terminal: terminal.handle + }).catch(() => undefined) + } + await client.dispose() + } + if (secondHost) { + await session.close(secondHost) + } + if (firstHost) { + await session.close(firstHost) + } + await session.dispose() + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + } +}) From 419151dddd54891e863206b1a01ea35ed6f8017b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:29:37 -0700 Subject: [PATCH 78/88] fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google binds a signed-in session to the browser identity that created it, and rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions died at ~1h and re-copying cookies only reset the same losing clock. Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every layer Google probes: the request headers (with sec-ch-ua* stripped, since real Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP emulation override a viewport preset installs. All three must agree — a disagreement between them is a sharper bot signal than any single wrong UA. Native-UA profiles opt out at every layer, including popup child windows and after a browser-native cookie import. Validated live against a real account: freshness cookies issued and rotating on a ~10-min cadence, session still signed in and self-renewing past 10 hours, against a pre-fix baseline of death at ~1h. Closes #11518. --- src/main/browser/browser-cookie-import.ts | 5 +- .../browser/browser-google-auth-ua.test.ts | 65 ++ src/main/browser/browser-google-auth-ua.ts | 51 ++ src/main/browser/browser-manager.test.ts | 812 +++++++++++++++++- src/main/browser/browser-manager.ts | 231 ++++- ...owser-session-registry.persistence.test.ts | 17 +- .../browser/browser-session-registry.test.ts | 80 +- src/main/browser/browser-session-registry.ts | 14 +- src/main/browser/browser-session-ua.ts | 66 +- .../browser-session-user-agent-mode.ts | 22 + .../browser-viewport-user-agent.test.ts | 66 ++ .../browser/browser-viewport-user-agent.ts | 73 ++ 12 files changed, 1423 insertions(+), 79 deletions(-) create mode 100644 src/main/browser/browser-google-auth-ua.test.ts create mode 100644 src/main/browser/browser-google-auth-ua.ts create mode 100644 src/main/browser/browser-session-user-agent-mode.ts create mode 100644 src/main/browser/browser-viewport-user-agent.test.ts create mode 100644 src/main/browser/browser-viewport-user-agent.ts diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 131adcca66b..e67c5470be8 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -74,6 +74,7 @@ import type { BrowserSessionProfileSource } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { setupClientHintsOverride } from './browser-session-ua' import { isGoogleSourceBoundCookie, @@ -1876,7 +1877,9 @@ export async function importCookiesFromBrowser( const ua = getUserAgentForBrowser(browser.family) if (ua) { targetSession.setUserAgent(ua) - setupClientHintsOverride(targetSession, ua) + setupClientHintsOverride(targetSession, ua, { + googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native' + }) browserSessionRegistry.persistUserAgent(targetPartition, ua) diag(` set UA for partition: ${ua.substring(0, 80)}...`) } diff --git a/src/main/browser/browser-google-auth-ua.test.ts b/src/main/browser/browser-google-auth-ua.test.ts new file mode 100644 index 00000000000..a1ed1370418 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { + googleAuthUserAgent, + isGoogleAuthUrl, + setUserAgentHeader, + stripClientHints +} from './browser-google-auth-ua' + +describe('isGoogleAuthUrl', () => { + it('matches the Google/YouTube sign-in hosts exactly', () => { + expect(isGoogleAuthUrl('https://accounts.google.com/')).toBe(true) + expect(isGoogleAuthUrl('https://accounts.google.com/v3/signin/identifier')).toBe(true) + expect(isGoogleAuthUrl('https://accounts.youtube.com/signin')).toBe(true) + expect(isGoogleAuthUrl('https://ACCOUNTS.GOOGLE.COM/')).toBe(true) + }) + + it('does not match post-auth app subdomains or lookalikes', () => { + expect(isGoogleAuthUrl('https://myaccount.google.com/')).toBe(false) + expect(isGoogleAuthUrl('https://mail.google.com/')).toBe(false) + expect(isGoogleAuthUrl('https://accounts.google.com.evil.test/')).toBe(false) + expect(isGoogleAuthUrl('https://google.com/')).toBe(false) + expect(isGoogleAuthUrl('not a url')).toBe(false) + }) +}) + +describe('googleAuthUserAgent', () => { + it('produces an internally consistent Firefox UA for the host platform', () => { + const ua = googleAuthUserAgent() + expect(ua).toMatch(/^Mozilla\/5\.0 \(.+; rv:\d+\.0\) Gecko\/20100101 Firefox\/\d+\.0$/) + expect(ua).not.toContain('Chrome') + expect(ua).not.toContain('Electron') + }) +}) + +describe('stripClientHints', () => { + it('removes every sec-ch-ua* header regardless of case, keeps others', () => { + const headers: Record = { + 'sec-ch-ua': 'a', + 'Sec-CH-UA-Platform': 'b', + 'sec-ch-ua-full-version-list': 'c', + 'User-Agent': 'ua', + Accept: 'text/html' + } + stripClientHints(headers) + expect(Object.keys(headers).some((k) => k.toLowerCase().startsWith('sec-ch-ua'))).toBe(false) + expect(headers['User-Agent']).toBe('ua') + expect(headers.Accept).toBe('text/html') + }) +}) + +describe('setUserAgentHeader', () => { + it('overwrites an existing user-agent header in place, preserving its casing', () => { + const headers: Record = { 'user-agent': 'old' } + setUserAgentHeader(headers, 'new') + expect(headers['user-agent']).toBe('new') + expect(Object.keys(headers)).toEqual(['user-agent']) + }) + + it('adds a canonical header when none exists', () => { + const headers: Record = {} + setUserAgentHeader(headers, 'new') + expect(headers['User-Agent']).toBe('new') + }) +}) diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts new file mode 100644 index 00000000000..4392e6351c2 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.ts @@ -0,0 +1,51 @@ +// Why: Google binds a signed-in session to the browser identity that created it. +// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped +// UA that doesn't match a real first-party browser) get flagged by anti-fraud on +// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped +// to Google's auth hosts lets the user sign in *inside* the embedded browser, so +// Google issues cookies bound to THIS browser that self-refresh — instead of us +// transplanting cookies that go stale. Scope is deliberately the auth hosts only: +// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep +// the profile's real Chrome-shaped identity so nothing else about the session shifts. + +// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth +// app surfaces, not the sign-in flow, and must retain the profile's real identity. +const GOOGLE_AUTH_HOSTS = new Set(['accounts.google.com', 'accounts.youtube.com']) + +export function isGoogleAuthUrl(rawUrl: string): boolean { + try { + return GOOGLE_AUTH_HOSTS.has(new URL(rawUrl).hostname.toLowerCase()) + } catch { + return false + } +} + +// Why: rv:/Gecko/Firefox tokens must line up with a real released build and the +// platform token must match the host OS, or the UA is internally inconsistent and +// itself a bot tell. +export function googleAuthUserAgent(): string { + const platform = + process.platform === 'darwin' + ? 'Macintosh; Intel Mac OS X 10.15' + : process.platform === 'win32' + ? 'Windows NT 10.0; Win64; x64' + : 'X11; Linux x86_64' + return `Mozilla/5.0 (${platform}; rv:140.0) Gecko/20100101 Firefox/140.0` +} + +// Why: real Firefox emits no sec-ch-ua* client hints; leaving Chromium's hints on a +// Firefox UA is a sharper mismatch than either signal alone. +export function stripClientHints(headers: Record): void { + for (const key of Object.keys(headers)) { + if (key.toLowerCase().startsWith('sec-ch-ua')) { + delete headers[key] + } + } +} + +// Why: the user-agent request header may already carry a base identity under a +// different case; overwrite the existing key rather than adding a duplicate. +export function setUserAgentHeader(headers: Record, value: string): void { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + headers[existing ?? 'User-Agent'] = value +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index fcda3846045..2119c1fe11c 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -55,9 +55,18 @@ vi.mock('./popup-origin-bar-window', () => ({ })) import { browserManager } from './browser-manager' +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' describe('browserManager', () => { const rendererWebContentsId = 5001 + // Base (non-Firefox) UA a guest reports off the Google auth hosts. + const guestBaseUserAgent = 'Mozilla/5.0 (Test) Chrome/140.0.0.0' + const guestUaMethods = () => ({ + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + }) type DownloadItemHandlerState = 'progressing' | 'interrupted' | 'completed' | 'cancelled' type DownloadItemHandler = (event: Electron.Event, state: DownloadItemHandlerState) => void @@ -1610,7 +1619,8 @@ describe('browserManager', () => { on: guestOnMock, off: guestOffMock, openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'http://localhost:3000/') + getURL: vi.fn(() => 'http://localhost:3000/'), + ...guestUaMethods() } webContentsFromIdMock.mockImplementation((id: number) => { @@ -1686,7 +1696,8 @@ describe('browserManager', () => { on: guestOnMock, off: guestOffMock, openDevTools: guestOpenDevToolsMock, - getURL: vi.fn(() => 'https://example.com/') + getURL: vi.fn(() => 'https://example.com/'), + ...guestUaMethods() } webContentsFromIdMock.mockImplementation((id: number) => id === guest.id @@ -1738,7 +1749,8 @@ describe('browserManager', () => { off: guestOffMock, openDevTools: guestOpenDevToolsMock, send: vi.fn(), - getURL: vi.fn(() => 'chrome-error://chromewebdata/') + getURL: vi.fn(() => 'chrome-error://chromewebdata/'), + ...guestUaMethods() } webContentsFromIdMock.mockReturnValue(guest) @@ -1780,7 +1792,8 @@ describe('browserManager', () => { off: guestOffMock, openDevTools: guestOpenDevToolsMock, send: vi.fn(), - getURL: vi.fn(() => 'chrome-error://chromewebdata/') + getURL: vi.fn(() => 'chrome-error://chromewebdata/'), + ...guestUaMethods() } webContentsFromIdMock.mockReturnValue(guest) @@ -1819,6 +1832,172 @@ describe('browserManager', () => { expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) + it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => { + let currentUa = guestBaseUserAgent + const setUserAgent = vi.fn((ua: string) => { + currentUa = ua + }) + const guest = { + id: 408, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => currentUa), + setUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-auth-ua', + webContentsId: guest.id, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + setUserAgent.mockClear() + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + + // Off the auth host, the guest's base identity is restored. + didStartNavigation(null, 'https://myaccount.google.com/', false, true) + expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent) + + // A navigation that doesn't change the required UA must not thrash setUserAgent. + setUserAgent.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + it('leaves the UA untouched on Google auth hosts for native-UA profiles', () => { + const setUserAgent = vi.fn() + const guest = { + id: 409, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-native-ua', + webContentsId: guest.id, + rendererWebContentsId, + userAgentMode: 'native' + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + setUserAgent.mockClear() + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + it('honors native session mode before the guest registration IPC arrives', () => { + const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) } + setBrowserSessionUserAgentMode(nativeSession as never, 'native') + const setUserAgent = vi.fn() + const guest = { + id: 417, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent, + session: nativeSession + } + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + + // Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId, + // so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing — + // native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the + // wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface. + it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => { + const ownerGuest = { + id: 415, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: vi.fn(), + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + webContentsFromIdMock.mockReturnValue(ownerGuest) + browserManager.attachGuestPolicies(ownerGuest as never) + browserManager.registerGuest({ + browserPageId: 'browser-native-popup-owner', + webContentsId: ownerGuest.id, + rendererWebContentsId, + userAgentMode: 'native' + }) + + // The popup carries its own listeners so its handler is unambiguous. + const popupOn = vi.fn() + const popupSetUserAgent = vi.fn() + const popupGuest = { + id: 416, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: popupOn, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent: popupSetUserAgent, + session: { getUserAgent: vi.fn(() => guestBaseUserAgent) } + } + browserManager.attachGuestPolicies(popupGuest as never, { + browserTabId: 'browser-native-popup-owner', + rootGuestWebContentsId: ownerGuest.id + }) + + const popupDidStartNavigation = popupOn.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + expect(popupDidStartNavigation).toBeDefined() + + popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(popupSetUserAgent).not.toHaveBeenCalled() + }) + it('queues permission denials and download requests until the guest registers', () => { const rendererSendMock = vi.fn() const guest = { @@ -2806,23 +2985,46 @@ describe('browserManager', () => { }) describe('setViewportOverride', () => { - function makeGuest(id: number): { + const GUEST_ELECTRON_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' + const GUEST_CLEAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' + + // Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one + // microtask hop; loop until the chain is empty rather than guessing a tick count. + async function flushViewportOps(): Promise { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + function makeGuest( + id: number, + url = 'https://example.com/' + ): { guest: Record debuggerSendCommand: ReturnType debuggerIsAttached: ReturnType debuggerAttach: ReturnType + setGuestUserAgent: (ua: string) => void + commitNavigationTo: (nextUrl: string) => void } { const debuggerSendCommand = vi.fn().mockResolvedValue(undefined) const debuggerIsAttached = vi.fn(() => true) const debuggerAttach = vi.fn() + let currentUa = GUEST_ELECTRON_UA + // Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation. + let committedUrl = url const guest = { id, isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), - getUserAgent: vi.fn( - () => - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' - ), + getURL: vi.fn(() => committedUrl), + getUserAgent: vi.fn(() => currentUa), + setUserAgent: vi.fn((ua: string) => { + currentUa = ua + }), + session: { getUserAgent: vi.fn(() => GUEST_ELECTRON_UA) }, setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -2835,7 +3037,18 @@ describe('browserManager', () => { sendCommand: debuggerSendCommand } } - return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach } + return { + guest, + debuggerSendCommand, + debuggerIsAttached, + debuggerAttach, + setGuestUserAgent: (ua: string) => { + currentUa = ua + }, + commitNavigationTo: (nextUrl: string) => { + committedUrl = nextUrl + } + } } it('returns false when the tab is not registered', async () => { @@ -2921,6 +3134,585 @@ describe('browserManager', () => { } ) + // Why: the CDP override outranks setUserAgent for navigator.userAgent, so a preset applied on an + // auth host must carry the same Firefox identity the header hook sends (verified against real + // Electron 43: Emulation.setUserAgentOverride wins over WebContents.setUserAgent and stands + // across every later navigation until explicitly cleared). + it.each([false, true])( + 'presents the Firefox UA for a preset applied on a Google auth host (mobile=%s)', + async (mobile) => { + const { guest, debuggerSendCommand } = makeGuest( + mobile ? 4246 : 4245, + 'https://accounts.google.com/v3/signin/identifier' + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: `tab-auth-${mobile}`, + webContentsId: guest.id as number, + rendererWebContentsId + }) + + await browserManager.setViewportOverride(`tab-auth-${mobile}`, { + width: mobile ? 375 : 1024, + height: mobile ? 667 : 768, + deviceScaleFactor: mobile ? 2 : 1, + mobile + }) + + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + } + ) + + it('re-issues the standing UA override when navigating onto and back off an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4247) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-nav', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + // Case A: the desktop preset lands first, while the tab is still off the auth host. + await browserManager.setViewportOverride('tab-auth-nav', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_CLEAN_UA + }) + + // Navigating to the auth host must move the standing override to the Firefox identity. + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + // Leaving the auth host restores the clean Chrome-shaped preset UA. + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://example.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: GUEST_CLEAN_UA + }) + }) + + // Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so + // the later-issued write always wins. The defect is post-await staleness: verified against real + // Electron 43.1.0, did-start-navigation fires while an awaited sendCommand is still pending, and + // getURL() keeps reporting the OUTGOING page until commit. A preset resuming mid-navigation + // therefore resolves the wrong host and wins with the wrong value. Both writers must resolve the + // host from the in-flight navigation target instead. + function lastUserAgentOverride( + debuggerSendCommand: ReturnType + ): Record | undefined { + const calls = debuggerSendCommand.mock.calls.filter( + (call) => call[0] === 'Emulation.setUserAgentOverride' + ) + return calls.at(-1)?.[1] as Record | undefined + } + + // Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent + // has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox + // UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile + // branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect. + it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/') + // Hold the preset's first CDP command open so the navigation lands inside its await window. + let releaseMetrics = (): void => {} + const metricsGate = new Promise((resolve) => { + releaseMetrics = () => resolve() + }) + debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-race-onto-auth', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + const presetDone = browserManager.setViewportOverride('tab-race-onto-auth', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + await flushViewportOps() + + // The tab navigates to the auth host while the preset is still awaiting its first command. + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + releaseMetrics() + await presetDone + await flushViewportOps() + + // Without a shared chain + shared URL source, the preset resumes, reads getURL() as the + // pre-navigation host, and pins the tab to the Chrome-shaped UA while it is on the auth host. + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + }) + + it('does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host', async () => { + const { guest, debuggerSendCommand } = makeGuest(4252, 'https://accounts.google.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-race-off-auth', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + // Establish a standing preset while the tab really is on the auth host. + await browserManager.setViewportOverride('tab-race-off-auth', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + + // A second preset change is now in flight when the tab leaves the auth host. + let releaseMetrics = (): void => {} + const metricsGate = new Promise((resolve) => { + releaseMetrics = () => resolve() + }) + debuggerSendCommand.mockImplementation((method: string) => + method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined) + ) + const presetDone = browserManager.setViewportOverride('tab-race-off-auth', { + width: 1440, + height: 900, + deviceScaleFactor: 2, + mobile: false + }) + await flushViewportOps() + + didStartNavigation(null, 'https://example.com/', false, true) + releaseMetrics() + await presetDone + await flushViewportOps() + + // Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the + // navigation's correct write, stranding the Firefox UA on a non-auth page. + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('falls back to the committed URL once a navigation commits or fails', async () => { + const { guest, debuggerSendCommand, commitNavigationTo } = makeGuest( + 4253, + 'https://example.com/' + ) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-pending-cleared', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + const didNavigate = guestOnMock.mock.calls.find( + ([event]) => event === 'did-navigate' + )?.[1] as (event: unknown, url: string) => void + + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + + // An aborted navigation to the auth host must not leave that host standing as the tab's + // identity: the tab never went there, so a later preset must resolve the committed URL. + didStartNavigation(null, 'https://accounts.google.com/', false, true) + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + + // A later preset must also resolve the committed, non-auth URL. + debuggerSendCommand.mockClear() + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone') + + // Same after a successful commit: the committed URL takes over from the pending target. + didStartNavigation(null, 'https://accounts.google.com/signin', false, true) + commitNavigationTo('https://accounts.google.com/signin') + didNavigate(null, 'https://accounts.google.com/signin') + await flushViewportOps() + + debuggerSendCommand.mockClear() + await browserManager.setViewportOverride('tab-pending-cleared', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + }) + + it('does not let a superseded navigation failure revert a newer target', async () => { + const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-overlapping-navs', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-overlapping-navs', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) + didStartNavigation(null, 'https://accounts.google.com/same', false, true) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) + ) + }) + + it('switches identity for a server redirect and restores it if the redirect fails', async () => { + const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-redirect', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-auth-redirect', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/redirected', + false, + true + ) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) + await flushViewportOps() + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('reapplies a preset when navigation starts during its final UA write', async () => { + const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/') + let releaseFirstUa = (): void => {} + const firstUaGate = new Promise((resolve) => { + releaseFirstUa = resolve + }) + let uaWrites = 0 + debuggerSendCommand.mockImplementation((method: string) => { + if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) { + return firstUaGate + } + return Promise.resolve(undefined) + }) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-apply-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + const presetDone = browserManager.setViewportOverride('tab-final-apply-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + releaseFirstUa() + await presetDone + }) + + it('does not reinstall a preset while its final UA clear is in flight', async () => { + const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-clear-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-final-clear-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + let releaseClearUa = (): void => {} + const clearUaGate = new Promise((resolve) => { + releaseClearUa = resolve + }) + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? clearUaGate + : Promise.resolve(undefined) + ) + const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + releaseClearUa() + await clearDone + }) + + // Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry + // first makes it untracked, so the navigation path can never correct it again and the tab carries + // a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents. + it('keeps tracking the standing override when the CDP clear fails', async () => { + const { guest, debuggerSendCommand } = makeGuest(4254, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-failed-clear', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-failed-clear', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + + // The final UA clear fails after tracking was optimistically removed. + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? Promise.reject(new Error('debugger detached')) + : Promise.resolve(undefined) + ) + await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe( + false + ) + await flushViewportOps() + + // The override is still standing on the target, so navigation must still be able to correct it. + debuggerSendCommand.mockImplementation(() => Promise.resolve(undefined)) + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + await flushViewportOps() + expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + }) + + it('does not touch the UA override on navigation when no preset is standing', async () => { + const { guest, debuggerSendCommand } = makeGuest(4248) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-no-preset', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + it('stops re-issuing the UA override once the preset is cleared', async () => { + const { guest, debuggerSendCommand } = makeGuest(4249) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-cleared-preset', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-cleared-preset', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await browserManager.setViewportOverride('tab-cleared-preset', null) + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + + it('leaves the UA override alone on navigation for native-UA profiles', async () => { + const { guest, debuggerSendCommand } = makeGuest(4250) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-native-nav', + sessionProfileId: 'native-profile', + userAgentMode: 'native', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-native-nav', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.anything() + ) + }) + it('clears device metrics and disables touch for override=null', async () => { const { guest, debuggerSendCommand } = makeGuest(4343) webContentsFromIdMock.mockReturnValue(guest) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index d4547abada8..fbcac378412 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,6 +43,9 @@ import { buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' import type { BrowserViewportOverride, BrowserCertificateFailure, @@ -130,16 +133,6 @@ function isAutomationVisibilityToken(token: unknown): token is string { return typeof token === 'string' && token.length > 0 } -// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). -function buildMobileUserAgent(chromeMajor: string): string { - return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` -} - -function extractChromeMajor(ua: string): string { - const match = ua.match(/Chrome\/(\d+)/) - return match ? match[1] : '134' -} - export type BrowserGuestRegistration = { browserPageId?: string browserTabId?: string @@ -158,6 +151,10 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -231,6 +228,12 @@ export class BrowserManager { private readonly rendererWebContentsIdByTabId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. private readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + private readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + private readonly pendingNavigationByGuestId = new Map() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() @@ -781,20 +784,35 @@ export class BrowserManager { return { action: 'deny' } }) - const navigationGuard = (event: Electron.Event, url: string): void => { + const navigationGuard = (event: Electron.Event, url: string): boolean => { // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return + return true } // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. if (url.startsWith('file:')) { event.preventDefault() - return + return false } if (!normalizeBrowserNavigationUrl(url)) { // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. event.preventDefault() + return false } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) } const didFailLoadHandler = ( @@ -807,6 +825,12 @@ export class BrowserManager { if (!isMainFrame) { return } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } const browserPageId = this.tabIdByWebContentsId.get(guest.id) const certificateFailure = browserPageId ? this.certificateTrustController?.getFailure(browserPageId) @@ -850,6 +874,10 @@ export class BrowserManager { if (!isMainFrame || isChromiumInternalErrorUrl(url)) { return } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. this.pendingLoadFailuresByGuestId.delete(guest.id) @@ -865,13 +893,15 @@ export class BrowserManager { } const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. this.clearedLoadErrorsByGuestId.delete(guest.id) this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', navigationGuard) + guest.on('will-redirect', willRedirectHandler) guest.on('did-start-navigation', didStartNavigationHandler) guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) @@ -905,7 +935,7 @@ export class BrowserManager { } if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', navigationGuard) + guest.off('will-redirect', willRedirectHandler) guest.off('did-start-navigation', didStartNavigationHandler) guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) @@ -913,6 +943,124 @@ export class BrowserManager { }) } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const currentUa = guest.getUserAgent() + if (isGoogleAuthUrl(url)) { + if (currentUa !== firefoxUa) { + guest.setUserAgent(firefoxUa) + } + } else if (currentUa === firefoxUa) { + // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + guest.setUserAgent(guest.session.getUserAgent()) + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + private startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + private updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + private failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + private resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + private reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + private async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + baseUserAgent: cleanElectronUserAgent(guest.getUserAgent()) + }) + ) + } + private createPopupChildWindowWithOriginBar( openerGuest: Electron.WebContents, targetUrl: string, @@ -961,6 +1109,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { @@ -1094,6 +1243,10 @@ export class BrowserManager { this.worktreeIdByTabId.delete(browserTabId) // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } @@ -1159,6 +1312,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.pendingNavigationByGuestId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() this.clearedLoadErrorsByGuestId.clear() @@ -1552,36 +1707,10 @@ export class BrowserManager { }) // Why: viewport sizing must not override a profile's explicit native-UA identity. if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - if (override.mobile) { - const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) - // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: buildMobileUserAgent(chromeMajor), - userAgentMetadata: { - brands: [ - { brand: 'Google Chrome', version: chromeMajor }, - { brand: 'Chromium', version: chromeMajor }, - { brand: 'Not/A)Brand', version: '24' } - ], - fullVersionList: [ - { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, - { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, - { brand: 'Not/A)Brand', version: '24.0.0.0' } - ], - fullVersion: `${chromeMajor}.0.0.0`, - platform: 'iOS', - platformVersion: '17.0', - architecture: '', - model: 'iPhone', - mobile: true - } - }) - } else { - // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. - await dbg.sendCommand('Emulation.setUserAgentOverride', { - userAgent: cleanElectronUserAgent(guest.getUserAgent()) - }) - } + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) } } else { await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) @@ -1589,8 +1718,18 @@ export class BrowserManager { enabled: false, maxTouchPoints: 0 }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + try { + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } } return true } catch { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 76ddaf4fee8..f7792903bac 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => { browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') expect(profileSession.setUserAgent).not.toHaveBeenCalled() expect(setupClientHintsOverrideMock).not.toHaveBeenCalled() + expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') }) it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { @@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => { setupClientHintsOverrideMock.mock.calls.some( (c: unknown[]) => (c[0] as { partition?: string } | undefined)?.partition === importedPartition && - c[1] === importedUa + c[1] === importedUa && + (c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false + ) + ).toBe(true) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' ) ).toBe(true) }) @@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => { ([sess]) => (sess as { partition?: string }).partition === importedPartition ) ).toBe(false) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' + ) + ).toBe(true) }) it('sets up default-partition policies on restore', async () => { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 31260f4cf4c..44e1b6b8e92 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -393,13 +393,89 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge') }) - it('does not register handler for non-Chrome UA', () => { + it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never + // Why: the Google-auth Firefox switch must install regardless of the base UA. setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)') - expect(onBeforeSendHeaders).not.toHaveBeenCalled() + expect(onBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['https://*/*'] }, + expect.any(Function) + ) + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old') + }) + + it('presents a Firefox UA and strips client hints on Google auth hosts', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { + 'User-Agent': 'Chrome/147', + 'sec-ch-ua': 'old', + 'sec-ch-ua-full-version-list': 'old', + 'sec-ch-ua-platform': '"macOS"' + } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['User-Agent']).toMatch(/Firefox\/\d/) + expect(modified['User-Agent']).not.toContain('Chrome') + expect(modified['sec-ch-ua']).toBeUndefined() + expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() + expect(modified['sec-ch-ua-platform']).toBeUndefined() + }) + + it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + + it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const importedUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['User-Agent']).toBe(importedUa) + expect(modified['sec-ch-ua']).toContain('Google Chrome') }) it('leaves non-Client-Hints headers unchanged', () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index e30c3a0230d..d6188bf88b7 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -26,6 +26,10 @@ import type { import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { + clearBrowserSessionUserAgentMode, + setBrowserSessionUserAgentMode +} from './browser-session-user-agent-mode' import { resolveChromiumCookiesPath } from './chromium-cookie-path' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { @@ -190,10 +194,14 @@ class BrowserSessionRegistry { const partition = profile.partition try { const sess = session.fromPartition(partition) + const userAgentMode = profile.userAgentMode ?? 'clean' + setBrowserSessionUserAgentMode(sess, userAgentMode) const persistedUa = meta.userAgentByPartition[partition] if (persistedUa) { sess.setUserAgent(persistedUa) - setupClientHintsOverride(sess, persistedUa) + setupClientHintsOverride(sess, persistedUa, { + googleAuthOverride: userAgentMode !== 'native' + }) continue } @@ -435,6 +443,7 @@ class BrowserSessionRegistry { // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { const sess = session.fromPartition(profile.partition) + clearBrowserSessionUserAgentMode(sess) this.clearSessionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() @@ -523,11 +532,12 @@ class BrowserSessionRegistry { private setupSessionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile + const sess = session.fromPartition(partition) + setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') if (this.configuredPartitions.has(partition)) { return } - const sess = session.fromPartition(partition) browserManager.installCertificateRequestGuard(sess) if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index f30ce96fc0f..081e1a5deba 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,5 +1,12 @@ import type { Session } from 'electron' +import { + googleAuthUserAgent, + isGoogleAuthUrl, + setUserAgentHeader, + stripClientHints +} from './browser-google-auth-ua' + // Why: Electron's default UA includes "Electron/X.X.X" and the app name // (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors // flag as non-human traffic. Strip those tokens so the webview's UA and @@ -19,10 +26,46 @@ export function cleanElectronUserAgent(ua: string): string { // reveal the real version, creating a mismatch that Google's anti-fraud // detection flags as CookieMismatch on accounts.google.com. Override Client // Hints on outgoing requests to match the source browser's UA. -export function setupClientHintsOverride(sess: Session, ua: string): void { +export function setupClientHintsOverride( + sess: Session, + ua: string, + options: { googleAuthOverride?: boolean } = {} +): void { + // Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the + // Google-auth Firefox switch below must install regardless, so keep the hints + // optional rather than bailing out of the whole handler. + const chromeHints = buildChromeClientHints(ua) + const firefoxUa = googleAuthUserAgent() + + sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { + const headers = details.requestHeaders + if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) { + // Why: present a Firefox identity on Google's sign-in hosts so the user logs + // in inside the app and Google issues self-refreshing bound cookies. Strip + // sec-ch-ua* because real Firefox sends none. + setUserAgentHeader(headers, firefoxUa) + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } + if (chromeHints) { + for (const key of Object.keys(headers)) { + const lower = key.toLowerCase() + if (lower === 'sec-ch-ua') { + headers[key] = chromeHints.secChUa + } else if (lower === 'sec-ch-ua-full-version-list') { + headers[key] = chromeHints.secChUaFull + } + } + } + callback({ requestHeaders: headers }) + }) +} + +function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null { const chromeMatch = ua.match(/Chrome\/([\d.]+)/) if (!chromeMatch) { - return + return null } const fullChromeVersion = chromeMatch[1] const majorVersion = fullChromeVersion.split('.')[0] @@ -37,19 +80,8 @@ export function setupClientHintsOverride(sess: Session, ua: string): void { } const brandMajor = brandFullVersion.split('.')[0] - const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"` - const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` - - sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { - const headers = details.requestHeaders - for (const key of Object.keys(headers)) { - const lower = key.toLowerCase() - if (lower === 'sec-ch-ua') { - headers[key] = secChUa - } else if (lower === 'sec-ch-ua-full-version-list') { - headers[key] = secChUaFull - } - } - callback({ requestHeaders: headers }) - }) + return { + secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`, + secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` + } } diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts new file mode 100644 index 00000000000..8fa8f71d1a9 --- /dev/null +++ b/src/main/browser/browser-session-user-agent-mode.ts @@ -0,0 +1,22 @@ +import type { Session } from 'electron' + +import type { BrowserSessionUserAgentMode } from '../../shared/types' + +const userAgentModeBySession = new WeakMap() + +export function setBrowserSessionUserAgentMode( + session: Session, + mode: BrowserSessionUserAgentMode +): void { + userAgentModeBySession.set(session, mode) +} + +export function getBrowserSessionUserAgentMode( + session: Session +): BrowserSessionUserAgentMode | undefined { + return userAgentModeBySession.get(session) +} + +export function clearBrowserSessionUserAgentMode(session: Session): void { + userAgentModeBySession.delete(session) +} diff --git a/src/main/browser/browser-viewport-user-agent.test.ts b/src/main/browser/browser-viewport-user-agent.test.ts new file mode 100644 index 00000000000..1d069a209ad --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { googleAuthUserAgent } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' + +const CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' + +describe('buildViewportUserAgentOverride', () => { + it('presents the Firefox UA on Google auth hosts regardless of the preset', () => { + for (const mobile of [false, true]) { + const override = buildViewportUserAgentOverride({ + url: 'https://accounts.google.com/v3/signin/identifier', + mobile, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(googleAuthUserAgent()) + // Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers. + expect(override.userAgentMetadata).toBeUndefined() + } + }) + + it('keeps the clean desktop UA off the auth hosts', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://myaccount.google.com/', + mobile: false, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(CHROME_UA) + expect(override.userAgentMetadata).toBeUndefined() + }) + + it('splices the real Chrome major into the mobile UA and its client hints', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://example.com/', + mobile: true, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toContain('iPhone') + expect(override.userAgent).toContain('CriOS/134.0.0.0') + expect(override.userAgentMetadata?.mobile).toBe(true) + expect(override.userAgentMetadata?.platform).toBe('iOS') + expect(override.userAgentMetadata?.brands).toContainEqual({ + brand: 'Google Chrome', + version: '134' + }) + }) + + it('falls back to a known Chrome major when the base UA carries none', () => { + const override = buildViewportUserAgentOverride({ + url: 'https://example.com/', + mobile: true, + baseUserAgent: googleAuthUserAgent() + }) + expect(override.userAgent).toContain('CriOS/134.0.0.0') + }) + + it('treats an unparseable URL as a non-auth host', () => { + const override = buildViewportUserAgentOverride({ + url: 'not a url', + mobile: false, + baseUserAgent: CHROME_UA + }) + expect(override.userAgent).toBe(CHROME_UA) + }) +}) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts new file mode 100644 index 00000000000..7dedf8d8a6e --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -0,0 +1,73 @@ +// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both +// navigator.userAgent and the outgoing request header, and it stands across every later +// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer +// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces +// the exact UA mismatch this scope exists to remove. + +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' + +type UserAgentBrand = { brand: string; version: string } + +export type ViewportUserAgentOverride = { + userAgent: string + userAgentMetadata?: { + brands: UserAgentBrand[] + fullVersionList: UserAgentBrand[] + fullVersion: string + platform: string + platformVersion: string + architecture: string + model: string + mobile: boolean + } +} + +// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real +// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). +function buildMobileUserAgent(chromeMajor: string): string { + return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` +} + +function extractChromeMajor(ua: string): string { + const match = ua.match(/Chrome\/(\d+)/) + return match ? match[1] : '134' +} + +export function buildViewportUserAgentOverride(args: { + url: string + mobile: boolean + baseUserAgent: string +}): ViewportUserAgentOverride { + if (isGoogleAuthUrl(args.url)) { + // Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real + // Firefox emits no client hints, so Chrome brands here would contradict the stripped headers. + return { userAgent: googleAuthUserAgent() } + } + if (!args.mobile) { + // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. + return { userAgent: args.baseUserAgent } + } + const chromeMajor = extractChromeMajor(args.baseUserAgent) + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. + return { + userAgent: buildMobileUserAgent(chromeMajor), + userAgentMetadata: { + brands: [ + { brand: 'Google Chrome', version: chromeMajor }, + { brand: 'Chromium', version: chromeMajor }, + { brand: 'Not/A)Brand', version: '24' } + ], + fullVersionList: [ + { brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` }, + { brand: 'Chromium', version: `${chromeMajor}.0.0.0` }, + { brand: 'Not/A)Brand', version: '24.0.0.0' } + ], + fullVersion: `${chromeMajor}.0.0.0`, + platform: 'iOS', + platformVersion: '17.0', + architecture: '', + model: 'iPhone', + mobile: true + } + } +} From 02a1251c2d721a29e3b69848bc793f1e3aa21ef0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:13:14 -0700 Subject: [PATCH 79/88] fix(native-chat): classify diff lines whose content begins with -- or ++ (#12459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): stop diff colouring from misreading -- / ++ content lines as file headers diffFromText skipped every line starting with --- / +++ as a file header, so a deleted SQL/Lua '-- comment' (git emits '---') or an added '++flag' fell through to gray context with its marker still attached — and when it was the only change, the two-marker gate dropped the coloured diff entirely. Detect real headers structurally instead: an adjacent '--- ' / '+++ ' pair outside any hunk. A hunk header or 'diff --git' line now also proves the text is a diff, so a genuine single-line change renders while prose keeps the guard. Co-authored-by: Orca * test(native-chat): adopt #12335 diff-collision vectors and add mobile parity Pulls in @YuriNachos's test vectors from #12335 (header-less --- deletion, an adjacent --x/++y content pair, mobile re-export parity) and adds the spaced -- / ++ pair inside a hunk, which the pair-only rule in that PR misreads. Co-authored-by: Orca * fix(native-chat): keep bare --- / +++ rules out of the diff marker count Dropping the `---`/`+++` prefix exclusions made a bare `---` — a Markdown thematic break or YAML document separator — classify as a deletion. Tool results routinely carry those, so `---\na: 1\n---\nb: 2` went from correctly rejected to rendering as a red diff. A bare rule is never a file header (those need a path after the marker) and is only content inside a hunk, so treat it as meta when outside one. Fold the separate `isStructuredDiff` scan into the same pre-pass and skip non-marker lines early, so the added guard costs no extra traversal: 5.1 -> 4.3 us per 120-line prose result, diff path unchanged. --------- Co-authored-by: Orca --- .../session/mobile-native-chat-diff.test.ts | 12 ++ .../native-chat/native-chat-diff.test.ts | 132 ++++++++++++++++++ src/shared/native-chat-diff.ts | 71 +++++++++- 3 files changed, 210 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 3171b818050..69277dd09ae 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -36,6 +36,18 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) + // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ + // content collision must stay fixed on both platforms. + it('counts a removed -- comment line despite the --- prefix collision', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' } + ]) + }) + it('caps large diffs before creating render rows', () => { const text = Array.from( { length: 1_000 }, diff --git a/src/renderer/src/components/native-chat/native-chat-diff.test.ts b/src/renderer/src/components/native-chat/native-chat-diff.test.ts index 598b6b97d4a..229edc1e4bd 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.test.ts @@ -60,4 +60,136 @@ describe('diffFromText', () => { expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new']) expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old']) }) + + it('treats a removed line whose content starts with -- as a deletion', () => { + const diff = diffFromText( + '@@ -1,4 +1,1 @@\n SELECT 1;\n--- legacy comment\n-SELECT 2;\n-SELECT 3;' + ) + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,4 +1,1 @@' }, + { kind: 'context', text: ' SELECT 1;' }, + { kind: 'del', text: '-- legacy comment' }, + { kind: 'del', text: 'SELECT 2;' }, + { kind: 'del', text: 'SELECT 3;' } + ]) + }) + + it('treats an added line whose content starts with ++ as an addition', () => { + const diff = diffFromText('@@ -1,1 +1,3 @@\n ctx\n+++flag\n+normal') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['++flag', 'normal']) + }) + + it('classifies a C-style --i deletion inside a hunk', () => { + const diff = diffFromText('@@ -1,3 +1,2 @@\n int i;\n---i;\n-return i;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--i;', 'return i;']) + }) + + it('keeps real file headers as meta in full git diff output', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@', + ' SELECT 1;', + '--- legacy comment', + ' SELECT 2;' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'meta').map((l) => l.text)).toEqual([ + 'diff --git a/a.sql b/a.sql', + 'index 9eff25c..33791bf 100644', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,3 +1,2 @@' + ]) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['-- legacy comment']) + }) + + it('detects the second file headers in a multi-file diff', () => { + const diff = diffFromText( + [ + 'diff --git a/a.sql b/a.sql', + '--- a/a.sql', + '+++ b/a.sql', + '@@ -1,2 +1,1 @@', + '--- legacy comment', + ' SELECT 2;', + 'diff --git a/b.lua b/b.lua', + '--- a/b.lua', + '+++ b/b.lua', + '@@ -1,2 +1,1 @@', + '--- lua comment', + ' print(1)' + ].join('\n') + ) + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual([ + '-- legacy comment', + '-- lua comment' + ]) + }) + + it('renders a single-line change once hunk headers prove it is a diff', () => { + expect(diffFromText('@@ -1,2 +1,1 @@\n SELECT 1;\n--- legacy comment')).not.toBeNull() + // Without diff structure the two-marker prose guard still applies. + expect(diffFromText('-- legacy comment')).toBeNull() + }) + + it('classifies a --- content deletion in header-less agent-tool output', () => { + const diff = diffFromText('---sql comment\n+new\n-more context') + expect(diff).toEqual([ + { kind: 'del', text: '--sql comment' }, + { kind: 'add', text: 'new' }, + { kind: 'del', text: 'more context' } + ]) + }) + + it('does not read an adjacent --x / ++y content pair as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---note\n+++flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '--note' }, + { kind: 'add', text: '++flag' } + ]) + }) + + it('does not read a spaced -- / ++ content pair inside a hunk as a file header', () => { + const diff = diffFromText('@@ -1,2 +1,2 @@\n ctx\n--- note\n+++ flag') + expect(diff).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' ctx' }, + { kind: 'del', text: '-- note' }, + { kind: 'add', text: '++ flag' } + ]) + }) + + it('handles /dev/null headers for an added file', () => { + const diff = diffFromText('--- /dev/null\n+++ b/x.sql\n+-- new comment\n+SELECT 1;') + expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual([ + '-- new comment', + 'SELECT 1;' + ]) + expect(diff?.filter((l) => l.kind === 'del')).toEqual([]) + }) + + it('leaves a YAML document separator out of the marker count', () => { + expect(diffFromText('---\na: 1\n---\nb: 2')).toBeNull() + }) + + it('keeps a Markdown thematic break as meta rather than a deletion', () => { + const diff = diffFromText('Intro\n-----\n- alpha\n- beta') + expect(diff).toEqual([ + { kind: 'context', text: 'Intro' }, + { kind: 'meta', text: '-----' }, + { kind: 'del', text: ' alpha' }, + { kind: 'del', text: ' beta' } + ]) + }) + + it('still reads a bare --- inside a hunk as a deletion', () => { + const diff = diffFromText('@@ -1,3 +1,1 @@\n ctx\n---\n-SELECT 1;') + expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['--', 'SELECT 1;']) + }) }) diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 1e49d1e8969..0e9e242af6e 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -13,6 +13,59 @@ const DIFF_TRUNCATED_LINE: NativeChatDiffLine = { text: '… diff truncated …' } +const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/ +// Lines that open a new file section, so any hunk before them has ended. +const FILE_SECTION_START = + /^(?:diff |index |old mode |new mode |new file mode |deleted file mode |similarity index |dissimilarity index |rename |copy |Binary files )/ +// Markdown thematic break or YAML document separator, not a marker. +const BARE_RULE = /^(?:-{3,}|\+{3,})$/ + +type DiffStructure = { + /** Lines that are structure rather than content, so they never count as add/del. */ + metaIndices: Set + /** The text carries a hunk header or `diff --git`, so it is provably a diff. */ + isStructuredDiff: boolean +} + +/** + * Locates the `--- ` / `+++ ` file headers. A bare `---`/`+++` prefix + * is not enough to spot one: a removed line whose content began with `--` + * (SQL/Lua `-- comment`, C `--i`) is emitted as `---`. Real headers + * always come as an adjacent pair and never appear inside a hunk. + */ +function scanDiffStructure(lines: string[]): DiffStructure { + const metaIndices = new Set() + let isStructuredDiff = false + let inHunk = false + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (line.startsWith('@@')) { + inHunk = true + isStructuredDiff ||= HUNK_HEADER.test(line) + continue + } + if (FILE_SECTION_START.test(line)) { + inHunk = false + isStructuredDiff ||= line.startsWith('diff --git ') + continue + } + // Only marker lines can be a header or a rule; skip context and prose early. + if (inHunk || !(line.startsWith('-') || line.startsWith('+'))) { + continue + } + if (BARE_RULE.test(line)) { + metaIndices.add(index) + continue + } + if (line.startsWith('--- ') && (lines[index + 1] ?? '').startsWith('+++ ')) { + metaIndices.add(index) + metaIndices.add(index + 1) + index += 1 + } + } + return { metaIndices, isStructuredDiff } +} + function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } { if (typeof value !== 'string') { return { lines: [], truncated: false } @@ -62,23 +115,31 @@ export function diffFromText( return null } const bounded = toLines(text, maxLines) + const { metaIndices, isStructuredDiff } = scanDiffStructure(bounded.lines) let added = 0 let removed = 0 - const lines = bounded.lines.map((line): NativeChatDiffLine => { - if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { + const lines = bounded.lines.map((line, index): NativeChatDiffLine => { + if ( + metaIndices.has(index) || + line.startsWith('@@') || + line.startsWith('diff ') || + line.startsWith('index ') + ) { return { kind: 'meta', text: line } } - if (line.startsWith('+') && !line.startsWith('+++')) { + if (line.startsWith('+')) { added += 1 return { kind: 'add', text: line.slice(1) } } - if (line.startsWith('-') && !line.startsWith('---')) { + if (line.startsWith('-')) { removed += 1 return { kind: 'del', text: line.slice(1) } } return { kind: 'context', text: line } }) - if (added + removed < 2) { + // Proven diff text renders a single-line change; without that proof, two + // markers guard against colouring prose that merely opens a line with `-`. + if (added + removed < (isStructuredDiff ? 1 : 2)) { return null } return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines From 46b9d3b13a29d28cf2d6ad070d7aeefc07bbee85 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:55:54 -0700 Subject: [PATCH 80/88] Break out test and generated lines in branch line total (#13057) * rm comments * reduce comment --- ...us-branch-line-total-exec-contract.test.ts | 34 ++- .../status-branch-line-total-real-git.test.ts | 111 +++++++++- ...tus-branch-line-total-relay-parity.test.ts | 8 +- .../git-status-branch-line-total.test.ts | 22 +- .../SourceControl.branch-line-total.test.tsx | 35 +++- ...source-control-branch-context-row.test.tsx | 170 ++++++++++++++- .../source-control-branch-line-total-chip.tsx | 197 +++++++++++++++++- ...e-control-header-toolbar-identity.test.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 15 +- .../slices/editor-branch-line-total.test.ts | 86 +++++++- src/renderer/src/store/slices/editor.ts | 20 +- src/shared/generated-code-path.test.ts | 72 +++++++ src/shared/generated-code-path.ts | 50 +++++ src/shared/git-branch-line-total.test.ts | 93 ++++++++- src/shared/git-branch-line-total.ts | 40 +++- src/shared/git-status-types.ts | 4 + src/shared/test-code-path.test.ts | 61 ++++++ src/shared/test-code-path.ts | 43 ++++ 18 files changed, 995 insertions(+), 68 deletions(-) create mode 100644 src/shared/generated-code-path.test.ts create mode 100644 src/shared/generated-code-path.ts create mode 100644 src/shared/test-code-path.test.ts create mode 100644 src/shared/test-code-path.ts diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts index 13ece36daec..7dff12b5491 100644 --- a/src/main/git/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -67,6 +67,8 @@ import { } from './status' const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } const tempRoots: string[] = [] function git(repo: string, args: string[]): string { @@ -257,7 +259,13 @@ describe('branch line total exec budget', () => { getStatus(repo, { branchLineTotalMergeBase: mergeBase }) ]) - expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(first.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) expect(rangedDiffCalls()).toHaveLength(1) }) @@ -280,7 +288,13 @@ describe('branch line total exec budget', () => { getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) ]) - expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(first.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) // Proves the saving came from the branch-total coalescer: both passes really // ran their own `git status`, so the status lease merged nothing. @@ -306,7 +320,13 @@ describe('branch line total exec budget', () => { }) expect(second.branchLineTotal).toEqual(first.branchLineTotal) - expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(numstatCalls()).toEqual([]) }) @@ -324,7 +344,13 @@ describe('branch line total exec budget', () => { reuseLineStats: true }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase: laterMergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(rangedDiffCalls()).toHaveLength(1) }) }) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts index 80426c76e65..1748efb2907 100644 --- a/src/main/git/status-branch-line-total-real-git.test.ts +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -40,6 +40,9 @@ function commitAll(repo: string, message: string): string { return git(repo, ['rev-parse', 'HEAD']) } +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } + /** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { let added = 0 @@ -80,7 +83,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ ['staged', 1, 0], ['unstaged', 1, 1] @@ -97,7 +106,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('keeps the total across a commit and updates it on the next status call after an edit', async () => { @@ -107,15 +122,33 @@ describe('branch line total against a real repository', () => { await write(repo, 'f.txt', 'a\nb\nc\n') const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ + added: 2, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) commitAll(repo, 'commit the edit') const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ + added: 2, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) await write(repo, 'f.txt', 'a\nb\nc\nd\n') const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ + added: 3, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('counts a rename across the commit boundary once, using the post-rename path', async () => { @@ -129,7 +162,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 1, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('counts an untracked-only branch from the untracked file contents', async () => { @@ -141,7 +180,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) - expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 3, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { @@ -153,7 +198,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) it('contributes nothing for a pure rename', async () => { @@ -164,7 +215,33 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) - expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 0, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) + }) + + it('splits test-path and generated files out of the published total', async () => { + const repo = await createFixtureRepo() + await write(repo, 'src/a.ts', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/a.ts', 'a\nb\n') + await write(repo, 'src/a.test.ts', 't1\nt2\nt3\n') + await write(repo, 'pnpm-lock.yaml', 'l1\nl2\nl3\nl4\nl5\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ + added: 9, + removed: 0, + mergeBase, + test: { added: 3, removed: 0 }, + generated: { added: 5, removed: 0 } + }) }) it('includes untracked additions alongside tracked range changes', async () => { @@ -178,7 +255,13 @@ describe('branch line total against a real repository', () => { const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) - expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 5, + removed: 0, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) // Documented deviation from a pure `git diff`: the untracked half is added on @@ -198,6 +281,12 @@ describe('branch line total against a real repository', () => { ['untracked', 'gone.txt'] ]) expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) - expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + expect(result.branchLineTotal).toEqual({ + added: 2, + removed: 3, + mergeBase, + test: NO_LINES, + generated: NO_LINES + }) }) }) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts index e9f5240728c..c62d419a25f 100644 --- a/src/main/git/status-branch-line-total-relay-parity.test.ts +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -26,7 +26,13 @@ const execFileAsync = promisify(execFile) // flip.txt 0 (added in the branch commit, removed again in the worktree) // moved.txt 0 (pure rename) // fresh.txt +3 (untracked) -const EXPECTED_TOTAL = { added: 6, removed: 0 } +// No fixture path here looks like test or generated code, so it is all source. +const EXPECTED_TOTAL = { + added: 6, + removed: 0, + test: { added: 0, removed: 0 }, + generated: { added: 0, removed: 0 } +} // Summing the per-area status rows instead would give this — the wrong answer // the shared module exists to avoid. const AREA_ROW_SUM = { added: 5, removed: 2 } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts index ceb04351e07..ed20750d217 100644 --- a/src/relay/git-status-branch-line-total.test.ts +++ b/src/relay/git-status-branch-line-total.test.ts @@ -24,6 +24,8 @@ const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' // Untracked additions ride on top of the ranged diff, so the fixture needs a real file. const UNTRACKED_FILE = 'notes.md' const UNTRACKED_LINES = 4 +// No fixture path here looks like test or generated code, so it is all source. +const NO_LINES = { added: 0, removed: 0 } const STATUS_OUTPUT = [ '# branch.oid 1111111111111111111111111111111111111111', '# branch.head (detached)', @@ -166,7 +168,9 @@ describe('getStatusOp branch line total', () => { expect(result.branchLineTotal).toEqual({ added: 3 + UNTRACKED_LINES, removed: 2, - mergeBase + mergeBase, + test: NO_LINES, + generated: NO_LINES }) expect(rangedDiffCalls(git.mock.calls)).toEqual([ ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] @@ -352,7 +356,9 @@ describe('getStatusOp branch line total', () => { expect(first.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(reused.branchLineTotal).toEqual(first.branchLineTotal) expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) @@ -391,7 +397,9 @@ describe('getStatusOp branch line total', () => { expect(first.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) }) @@ -411,7 +419,9 @@ describe('getStatusOp branch line total', () => { expect(moved.branchLineTotal).toEqual({ added: 12 + UNTRACKED_LINES, removed: 5, - mergeBase: OTHER_MERGE_BASE + mergeBase: OTHER_MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) }) @@ -466,7 +476,9 @@ describe('getStatusOp branch line total', () => { expect(result.branchLineTotal).toEqual({ added: UNTRACKED_LINES, removed: 0, - mergeBase: MERGE_BASE + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES }) expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx index c9a7f24beb2..9f2529973dc 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -207,6 +207,12 @@ function chip(): HTMLElement | null { return container.querySelector('[data-testid="source-control-branch-line-total"]') } +function loadingChip(): HTMLElement | null { + return container.querySelector( + '[data-testid="source-control-branch-line-total-loading"]' + ) +} + describe('SourceControl branch line total request gate', () => { it('asks for a total when the panel is visible and compare is ready', () => { renderSourceControl() @@ -277,14 +283,14 @@ describe('SourceControl branch line total chip', () => { }) renderSourceControl() - expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions') + expect(chip()?.getAttribute('aria-label')).toBe('8259 lines added, 670 lines deleted') // Grouping is pinned to the app locale (`en`), not the runner's host locale. expect(chip()?.textContent).toBe('+8,259-670') }) it('drops a total whose fork point has since moved', () => { // Why: status and branch compare refresh on different cadences, so a total - // can outlive the merge base it measured. Hidden beats a stale number. + // can outlive the merge base it measured. Stale digits must not render. resetState({ gitBranchLineTotalByWorktree: { [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } @@ -293,6 +299,7 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) it('drops a published total while branch compare has no ready summary', () => { @@ -307,12 +314,27 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) - it('renders nothing when no total was published', () => { - renderSourceControl() + // Why: a pending total and one this host will never send look identical from + // here, so a pulsing placeholder would keep pulsing forever on an old host, + // after a hard failure, or during the ranged-diff cooldown. + it('shows no placeholder while the total is still pending', () => { + vi.useFakeTimers() + try { + renderSourceControl() - expect(chip()).toBeNull() + expect(chip()).toBeNull() + act(() => { + vi.advanceTimersByTime(20_000) + }) + expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() + expect(container.innerHTML).not.toContain('animate-pulse') + } finally { + vi.useRealTimers() + } }) it('renders nothing for an exact zero total', () => { @@ -324,6 +346,7 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()).toBeNull() + expect(loadingChip()).toBeNull() }) it('omits the zero half of a one-sided total', () => { @@ -335,6 +358,6 @@ describe('SourceControl branch line total chip', () => { renderSourceControl() expect(chip()?.textContent).toBe('+42') - expect(chip()?.getAttribute('aria-label')).toBe('42 additions') + expect(chip()?.getAttribute('aria-label')).toBe('42 lines added') }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 963e4e00a21..9ccad4f3acc 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -11,6 +11,12 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + const readySummary: GitBranchCompareSummary = { baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip', baseOid: 'base', @@ -241,7 +247,7 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).toContain('+8,259') expect(markup).toContain('-670') // Label reads raw digits; the grouped spans are decoration. - expect(markup).toContain('aria-label="8259 additions, 670 deletions"') + expect(markup).toContain('aria-label="8259 lines added, 670 lines deleted"') expect(markup).toContain('tabular-nums') expect(markup).toContain('text-[color:var(--git-decoration-added)]') expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') @@ -286,12 +292,12 @@ describe('SourceControlBranchContextRow branch line total', () => { const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' }) expect(addedOnly).toContain('>+42<') expect(addedOnly).not.toContain('>-0<') - expect(addedOnly).toContain('aria-label="42 additions"') + expect(addedOnly).toContain('aria-label="42 lines added"') const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' }) expect(removedOnly).toContain('>-7<') expect(removedOnly).not.toContain('>+0<') - expect(removedOnly).toContain('aria-label="7 deletions"') + expect(removedOnly).toContain('aria-label="7 lines deleted"') }) it('hides the chip when both counts are zero', () => { @@ -302,10 +308,13 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).not.toContain('>-0<') }) - it('hides the chip when the total is absent', () => { + it('renders nothing at all when the total is absent', () => { + // Why: a null total is equally "still computing" and "this host will never + // send one", so there is no placeholder to show — the slot stays empty. for (const total of [null, undefined]) { const markup = renderWithLineTotal(total) expect(markup).not.toContain('data-testid="source-control-branch-line-total"') + expect(markup).not.toContain('animate-pulse') expect(markup).not.toContain('NaN') } }) @@ -382,3 +391,156 @@ describe('SourceControlBranchContextRow branch line total', () => { } }) }) + +// The hover-card mock at the top renders content inline, so these assert the +// breakdown copy without driving a real hover. +describe('SourceControlBranchContextRow line total test split', () => { + it('breaks the panel down into test and non-test halves', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 } + }) + + expect(markup).toContain('Code breakdown') + expect(markup).toContain('Lines of code') + expect(markup).toContain('data-testid="source-control-branch-line-total-breakdown"') + expect(markup).toContain('Non-test') + expect(markup).toContain('>+123<') + expect(markup).toContain('>-109<') + expect(markup).toContain('Tests') + expect(markup).toContain('>+120<') + expect(markup).toContain('>-40<') + // Non-test first so the primary share leads the panel. + expect(markup.indexOf('Non-test')).toBeLessThan(markup.indexOf('Tests')) + }) + + it('spells the split into the label so the summary is announced closed', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 } + }) + + expect(markup).toContain( + 'aria-label="243 lines added, 149 lines deleted — test code: 120 lines added, 40 lines deleted"' + ) + }) + + it('keeps a zero test share visible rather than hiding the row', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 0, removed: 0 } + }) + + expect(markup).toContain('>+0<') + expect(markup).toContain('>-0<') + expect(markup).toContain('>+243<') + expect(markup).toContain('>-149<') + // Panel keeps the zero row; the spoken label stays the main totals only. + expect(markup).toContain('aria-label="243 lines added, 149 lines deleted"') + expect(markup).not.toContain('test code:') + }) + + // A host predating the split omits the field; inventing a 0% test share there + // would be a confidently wrong claim. + it('renders exactly as before when the host reported no split', () => { + const withoutSplit = renderWithLineTotal({ added: 243, removed: 149, mergeBase: 'base' }) + + expect(withoutSplit).not.toContain('Non-test') + expect(withoutSplit).not.toContain('Code breakdown') + expect(withoutSplit).toContain('aria-label="243 lines added, 149 lines deleted"') + }) + + it('labels the remainder Source once generated is known, and shows that row', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 20, removed: 10 }, + generated: { added: 100, removed: 50 } + }) + + expect(markup).toContain('Source') + expect(markup).not.toContain('Non-test') + expect(markup).toContain('Generated') + expect(markup).toContain('>+123<') // 243-20-100 + expect(markup).toContain('>-89<') // 149-10-50 + expect(markup).toContain('>+100<') + expect(markup).toContain('>-50<') + expect(markup).toContain( + 'aria-label="243 lines added, 149 lines deleted — test code: 20 lines added, 10 lines deleted — generated: 100 lines added, 50 lines deleted"' + ) + }) + + // The remainder here still contains tests, so calling it "Source" would claim + // a split the host never sent. + it('labels the remainder Non-generated when the host omitted the test field', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + generated: { added: 100, removed: 50 } + }) + + expect(markup).toContain('Non-generated') + expect(markup).toContain('>Generated<') + expect(markup).not.toContain('>Source<') + expect(markup).not.toContain('Tests') + expect(markup).toContain('>+143<') // 243-100 + expect(markup).toContain('>-99<') // 149-50 + }) + + // "No tests in this branch" is worth stating; "nothing was generated" is the + // normal case, so that row is dropped rather than shown as +0 -0. + it('drops the generated row and its announcement when nothing was generated', () => { + const markup = renderWithLineTotal({ + added: 243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 }, + generated: { added: 0, removed: 0 } + }) + + expect(markup).not.toContain('Generated') + expect(markup).not.toContain('generated:') + // The host did classify and found nothing, so the remainder is still Source. + expect(markup).toContain('Source') + expect(markup).toContain('Tests') + expect(markup).toContain('>+123<') + }) + + it('orders the rows source, tests, generated', () => { + const markup = renderWithLineTotal({ + added: 1243, + removed: 149, + mergeBase: 'base', + test: { added: 120, removed: 40 }, + generated: { added: 1000, removed: 0 } + }) + + expect(markup.indexOf('Source')).toBeLessThan(markup.indexOf('Tests')) + expect(markup.indexOf('Tests')).toBeLessThan(markup.indexOf('Generated')) + expect(markup).toContain('>+1,000<') + }) + + // The three rows must account for every line, or the panel contradicts the chip. + it('keeps the three rows summing back to the chip total', () => { + const markup = renderWithLineTotal({ + added: 500, + removed: 200, + mergeBase: 'base', + test: { added: 150, removed: 60 }, + generated: { added: 300, removed: 100 } + }) + + expect(markup).toContain('>+50<') // 500-150-300 + expect(markup).toContain('>-40<') // 200-60-100 + expect(markup).toContain('>+150<') + expect(markup).toContain('>+300<') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx index d7256ac0e8b..6db2d9aacec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react' import type { GitBranchLineTotal } from '../../../../shared/git-status-types' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { getIntlLocale, translate } from '@/i18n/i18n' // Why: raw digits, not the grouped display string — screen readers announce @@ -8,26 +9,106 @@ function buildAccessibleLabel(added: number, removed: number): string { if (added > 0 && removed > 0) { return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b', - '{{value0}} additions, {{value1}} deletions', + '{{value0}} lines added, {{value1}} lines deleted', { value0: added, value1: removed } ) } if (added > 0) { return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666', - '{{value0}} additions', + '{{value0}} lines added', { value0: added } ) } return translate( 'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d', - '{{value0}} deletions', + '{{value0}} lines deleted', { value0: removed } ) } -// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no -// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower. +// The chip takes no focus; the split still reaches assistive tech through the +// label rather than the hover-only panel. +function appendTestSplitToLabel(label: string, testAdded: number, testRemoved: number): string { + return translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.4c1f70ba92', + '{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted', + { value0: label, value1: testAdded, value2: testRemoved } + ) +} + +function appendGeneratedSplitToLabel(label: string, added: number, removed: number): string { + return translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.7f3e1a9c24', + '{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted', + { value0: label, value1: added, value2: removed } + ) +} + +type LineTotalSplitRow = { key: string; label: string; added: number; removed: number } + +function LineCountPair({ + added, + removed, + locale +}: { + added: number + removed: number + locale: string +}): React.JSX.Element { + return ( + + + +{added.toLocaleString(locale)} + + + -{removed.toLocaleString(locale)} + + + ) +} + +function CodeBreakdownPanel({ + rows, + locale +}: { + rows: LineTotalSplitRow[] + locale: string +}): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.a1b2c3d4e5', + 'Code breakdown' + )} +
+
+ {translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.b2c3d4e5f6', + 'Lines of code' + )} +
+
+
+ {rows.map((row) => ( +
+ {row.label} + +
+ ))} +
+
+ ) +} + +// Genuinely-empty (`+0 -0`), not-yet-published and unknown-after-timeout all +// render as nothing: no reserved width, no placeholder. There is no loading +// state because a null total is indistinguishable from permanent absence (old +// host, failed diff, cooldown), and the store now keeps the last published +// total across a soft miss so the slot rarely empties once filled. +// Not clickable — `openBranchAllDiffs` is narrower. export const SourceControlBranchLineTotalChip = React.memo( function SourceControlBranchLineTotalChip({ branchLineTotal @@ -38,12 +119,93 @@ export const SourceControlBranchLineTotalChip = React.memo( const removed = branchLineTotal?.removed ?? 0 const hasAdded = added > 0 const hasRemoved = removed > 0 + // Why: hosts predating either split omit the field; inventing a zero share + // there would be confidently wrong, so the breakdown is dropped per field. + const testTotal = branchLineTotal?.test + const testAdded = testTotal?.added + const testRemoved = testTotal?.removed + const generatedTotal = branchLineTotal?.generated + const generatedAdded = generatedTotal?.added + const generatedRemoved = generatedTotal?.removed // Full precision and app-locale-aware; a status tick that leaves the counts // alone must not rebuild these strings. const locale = getIntlLocale() const addedText = useMemo(() => added.toLocaleString(locale), [added, locale]) const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale]) - const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed]) + const accessibleLabel = useMemo(() => { + let label = buildAccessibleLabel(added, removed) + // Zero test share still shows in the hover panel; don't announce noise. + if (testAdded != null && testRemoved != null && (testAdded > 0 || testRemoved > 0)) { + label = appendTestSplitToLabel(label, testAdded, testRemoved) + } + if ( + generatedAdded != null && + generatedRemoved != null && + (generatedAdded > 0 || generatedRemoved > 0) + ) { + label = appendGeneratedSplitToLabel(label, generatedAdded, generatedRemoved) + } + return label + }, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved]) + const splitRows = useMemo(() => { + // Why: the fields ship together today but the type is independent so a + // host can gain one before the other; either alone is enough to render. + const hasTest = testAdded != null && testRemoved != null + const hasGenerated = generatedAdded != null && generatedRemoved != null + if (!hasTest && !hasGenerated) { + return [] + } + + const rows: LineTotalSplitRow[] = [ + { + key: 'source', + // Only "Source" once both splits are known — with one missing the + // remainder still contains the other bucket, so name what was taken out. + label: + hasTest && hasGenerated + ? translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.c8d5b21e07', + 'Source' + ) + : hasTest + ? translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.9e4a3c5081', + 'Non-test' + ) + : translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.3d7e9b1042', + 'Non-generated' + ), + added: added - (testAdded ?? 0) - (generatedAdded ?? 0), + removed: removed - (testRemoved ?? 0) - (generatedRemoved ?? 0) + } + ] + if (hasTest) { + rows.push({ + key: 'test', + label: translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.6b2d0f14a7', + 'Tests' + ), + added: testAdded, + removed: testRemoved + }) + } + // Why: "no tests in this branch" is worth showing as +0 -0; "nothing was + // generated" is the normal case, so that row is dropped instead. + if (hasGenerated && (generatedAdded > 0 || generatedRemoved > 0)) { + rows.push({ + key: 'generated', + label: translate( + 'auto.components.right.sidebar.source.control.branch.line.total.chip.7a04c6f8b3', + 'Generated' + ), + added: generatedAdded, + removed: generatedRemoved + }) + } + return rows + }, [added, removed, testAdded, testRemoved, generatedAdded, generatedRemoved]) if (!hasAdded && !hasRemoved) { return null @@ -51,12 +213,18 @@ export const SourceControlBranchLineTotalChip = React.memo( // Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone // keeps digits from jittering between refreshes. - return ( + // `cursor-help` signals hover detail without implying a click target. + const hasBreakdown = splitRows.length > 0 + const chip = ( {hasAdded ? ( ) + + if (!hasBreakdown) { + return chip + } + + return ( + + {chip} + + + + + ) } ) diff --git a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx index 4147569b7de..fac0f0b6ded 100644 --- a/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-header-toolbar-identity.test.tsx @@ -132,7 +132,7 @@ describe('SourceControlHeaderToolbar branch identity', () => { it('threads the branch line total down to the base-ref line', () => { const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } }) - expect(markup).toContain('aria-label="24 additions, 3 deletions"') + expect(markup).toContain('aria-label="24 lines added, 3 lines deleted"') expect(markup).toContain('+24') expect(markup).toContain('-3') }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 962bf2dbec1..568e139ebed 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11359,9 +11359,18 @@ "line": { "total": { "chip": { - "daa8e8e59b": "{{value0}} additions, {{value1}} deletions", - "8a9b97b666": "{{value0}} additions", - "52c366d88d": "{{value0}} deletions" + "daa8e8e59b": "{{value0}} lines added, {{value1}} lines deleted", + "8a9b97b666": "{{value0}} lines added", + "52c366d88d": "{{value0}} lines deleted", + "4c1f70ba92": "{{value0}} — test code: {{value1}} lines added, {{value2}} lines deleted", + "6b2d0f14a7": "Tests", + "9e4a3c5081": "Non-test", + "3d7e9b1042": "Non-generated", + "a1b2c3d4e5": "Code breakdown", + "b2c3d4e5f6": "Lines of code", + "7f3e1a9c24": "{{value0}} — generated: {{value1}} lines added, {{value2}} lines deleted", + "c8d5b21e07": "Source", + "7a04c6f8b3": "Generated" } } } diff --git a/src/renderer/src/store/slices/editor-branch-line-total.test.ts b/src/renderer/src/store/slices/editor-branch-line-total.test.ts index c4f88540951..2b7132085da 100644 --- a/src/renderer/src/store/slices/editor-branch-line-total.test.ts +++ b/src/renderer/src/store/slices/editor-branch-line-total.test.ts @@ -69,7 +69,7 @@ describe('createEditorSlice branch line total', () => { }) }) - it('clears a stored total when a later status omits the field', () => { + it('keeps the published total when a later status omits the field', () => { const store = createEditorStore() store .getState() @@ -77,12 +77,25 @@ describe('createEditorSlice branch line total', () => { 'wt-1', status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) ) + const published = store.getState().gitBranchLineTotalByWorktree['wt-1'] - // Why: an omitted field means "not known exact" — an old server, a failed - // numstat, or a timeout. Keeping the old number renders a confident lie. + // Why: an omitted field is "not computed on this pass" — a soft-deadline + // miss or a cooldown — and the host keeps backfilling its cache. Clearing + // it blanked the chip mid-poll and then flashed it back. store.getState().setGitStatus('wt-1', status()) - expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBe(published) + }) + + it('does not produce a new state object when a status merely omits the total', () => { + const store = createEditorStore() + const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { ...tick, branchLineTotal: undefined }) + + expect(store.getState()).toBe(before) }) it('clears a stored total when the listing hits the entry cap', () => { @@ -132,7 +145,7 @@ describe('createEditorSlice branch line total', () => { status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } }) ) - store.getState().setGitStatus('wt-1', status()) + store.getState().setGitStatus('wt-1', status({ didHitLimit: true, statusLength: 2 })) expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({ @@ -203,6 +216,69 @@ describe('createEditorSlice branch line total', () => { }) }) + // Moving a line from a source file into a test file leaves the totals + // identical; without the split in the equality check the hover would keep + // showing the old share. + it('produces a new state object when only the test split changed', () => { + const store = createEditorStore() + const tick = status({ + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + test: { added: 4, removed: 0 } + } + }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { + ...tick, + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + test: { added: 5, removed: 1 } + } + }) + + expect(store.getState()).not.toBe(before) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.test).toEqual({ + added: 5, + removed: 1 + }) + }) + + it('produces a new state object when only the generated split changed', () => { + const store = createEditorStore() + const tick = status({ + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + generated: { added: 8, removed: 0 } + } + }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { + ...tick, + branchLineTotal: { + added: 24, + removed: 3, + mergeBase: MERGE_BASE, + generated: { added: 12, removed: 1 } + } + }) + + expect(store.getState()).not.toBe(before) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']?.generated).toEqual({ + added: 12, + removed: 1 + }) + }) + it('leaves other worktree entries referentially stable when one total changes', () => { const store = createEditorStore() store diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index e3123db9cd1..971ab20cba6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -4193,15 +4193,29 @@ export const createEditorSlice: StateCreator = (s const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null) const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null - // Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip. - const nextBranchLineTotal = status.branchLineTotal ?? null + // Why: an omitted field means "not computed on this pass" — soft-deadline + // miss, cooldown, old host — not "zero", so dropping it blanks a published + // chip between polls. Staleness is handled where it can be: the host + // carries its cache forward, and SourceControl hides any total whose + // mergeBase no longer matches the fork point. A capped listing skips the + // ranged diff outright, so nothing will refresh it — clear it there. + const nextBranchLineTotal = status.didHitLimit + ? null + : (status.branchLineTotal ?? prevBranchLineTotal) const branchLineTotalUnchanged = prevBranchLineTotal === nextBranchLineTotal || (prevBranchLineTotal !== null && nextBranchLineTotal !== null && prevBranchLineTotal.added === nextBranchLineTotal.added && prevBranchLineTotal.removed === nextBranchLineTotal.removed && - prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase) + prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase && + (prevBranchLineTotal.test?.added ?? null) === (nextBranchLineTotal.test?.added ?? null) && + (prevBranchLineTotal.test?.removed ?? null) === + (nextBranchLineTotal.test?.removed ?? null) && + (prevBranchLineTotal.generated?.added ?? null) === + (nextBranchLineTotal.generated?.added ?? null) && + (prevBranchLineTotal.generated?.removed ?? null) === + (nextBranchLineTotal.generated?.removed ?? null)) const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId] const nextStatusHead = getKnownGitHead(status.head) diff --git a/src/shared/generated-code-path.test.ts b/src/shared/generated-code-path.test.ts new file mode 100644 index 00000000000..6af7617cf40 --- /dev/null +++ b/src/shared/generated-code-path.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { isGeneratedCodePath } from './generated-code-path' + +describe('isGeneratedCodePath', () => { + it('recognizes dependency lockfiles across ecosystems', () => { + for (const filePath of [ + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'Cargo.lock', + 'poetry.lock', + 'uv.lock', + 'Gemfile.lock', + 'composer.lock', + 'go.sum', + 'flake.lock', + 'mobile/pubspec.lock', + 'services/api/Pipfile.lock' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes tool-stamped filename suffixes', () => { + for (const filePath of [ + 'api/service.pb.go', + // grpc-gateway / protoc-gen-validate stack extra segments onto `.pb.` + 'api/service.pb.gw.go', + 'api/service.pb.validate.go', + 'api/service_pb2.py', + 'api/service_pb2_grpc.py', + 'src/schema.gen.ts', + 'internal/bindings_generated.go', + 'src/Api.generated.ts', + 'Forms/Main.Designer.cs', + 'lib/model.g.dart', + 'lib/model.freezed.dart', + 'public/app.min.js', + 'public/app.js.map', + 'src/components/__snapshots__/Chip.tsx.snap' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes generated directories on either separator', () => { + expect(isGeneratedCodePath('dist/renderer/index.js')).toBe(true) + expect(isGeneratedCodePath('src/__generated__/schema.ts')).toBe(true) + expect(isGeneratedCodePath('vendor/github.com/pkg/errors/errors.go')).toBe(true) + expect(isGeneratedCodePath('src\\__pycache__\\mod.pyc')).toBe(true) + }) + + it('leaves hand-written paths alone, including the ambiguous directory names', () => { + for (const filePath of [ + 'src/shared/git-branch-line-total.ts', + // Excluded on purpose: all common as authored source directories. + 'build/scripts/release.ts', + 'target/tracker.rs', + 'src/out/renderer.ts', + 'cmd/bin/main.go', + // Substrings, not whole segments or suffixes. + 'src/distributed/queue.ts', + 'src/vendors/stripe.ts', + 'src/generator/emit.ts', + 'docs/generated-code-policy.md', + 'src/shared/generated-code-path.ts' + ]) { + expect(isGeneratedCodePath(filePath), filePath).toBe(false) + } + }) +}) diff --git a/src/shared/generated-code-path.ts b/src/shared/generated-code-path.ts new file mode 100644 index 00000000000..b15dd020443 --- /dev/null +++ b/src/shared/generated-code-path.ts @@ -0,0 +1,50 @@ +/** + * Path-only heuristic for "was this file written by a tool?", used to carve + * machine-authored lines out of the branch total. A regenerated lockfile or + * protobuf stub can dwarf every hand-written line in a branch, which is what + * makes a bare `+8,259` misleading. + * + * Conservative on purpose: a false positive understates the real work, so only + * names that are unambiguous across ecosystems are listed. Matched against the + * raw path with anchored regexes, for the reasons in `test-code-path.ts`. + */ + +// Deliberately excludes `build`, `target`, `out` and `bin` — all common as +// hand-written source directories. +const GENERATED_DIRECTORY = + /(?:^|[/\\])(?:__generated__|__pycache__|\.next|\.nuxt|coverage|dist|generated|node_modules|vendor)[/\\]/i + +// Every dependency lockfile is regenerated wholesale, so its diff is churn +// rather than authored change. Kept separate from the suffix alternation below +// rather than merged into it: measured over 12.5k paths, two narrow anchored +// regexes reject a non-match faster than one wide alternation. +const LOCKFILE = + /(?:^|[/\\])(?:bun\.lockb?|cargo\.lock|composer\.lock|flake\.lock|gemfile\.lock|go\.sum|gradle\.lockfile|mix\.lock|npm-shrinkwrap\.json|package-lock\.json|packages\.lock\.json|pipfile\.lock|pnpm-lock\.yaml|poetry\.lock|pubspec\.lock|uv\.lock|yarn\.lock)$/i + +// The suffixes tools stamp onto their own output. +const GENERATED_BASENAME = new RegExp( + `(?:${[ + /\.(?:generated|designer)\.[^./\\]+/, // Foo.generated.ts, Form.Designer.cs + // Trailing segments are optional so plugin output keeps matching: + // service.pb.go, service_pb2.py, service.pb.gw.go, service.pb.validate.go. + /[._](?:pb|pb2|pb2_grpc)\.(?:[^./\\]+\.)*[^./\\]+/, + /[._]gen\.[^./\\]+/, // schema.gen.ts, mock_gen.go + /_generated\.[^./\\]+/, // bindings_generated.go + /\.(?:g|freezed)\.dart/, // model.g.dart + /\.min\.(?:js|css|mjs)/, + /\.(?:js|css|mjs)\.map/, // source maps + /\.snap/ // vitest/jest snapshots, rewritten by `-u` + ] + .map((pattern) => pattern.source) + .join('|')})$`, + 'i' +) + +/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */ +export function isGeneratedCodePath(filePath: string): boolean { + return ( + GENERATED_BASENAME.test(filePath) || + LOCKFILE.test(filePath) || + GENERATED_DIRECTORY.test(filePath) + ) +} diff --git a/src/shared/git-branch-line-total.test.ts b/src/shared/git-branch-line-total.test.ts index 054c4fc6897..1716da811d6 100644 --- a/src/shared/git-branch-line-total.test.ts +++ b/src/shared/git-branch-line-total.test.ts @@ -15,6 +15,7 @@ import { import type { GitLineStats } from './git-uncommitted-line-stats' const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4' +const NO_LINES = { added: 0, removed: 0 } const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c' function statsMap(entries: Record): ReadonlyMap { @@ -58,7 +59,7 @@ describe('sumGitBranchLineTotal', () => { }), untracked: statsMap({}) }) - ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE }) + ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('counts a half-binary entry on the side git did report', () => { @@ -68,7 +69,7 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'odd.txt': { added: 3 } }), untracked: statsMap({ 'new.bin': {} }) }) - ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE }) + ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('adds untracked additions on top of the tracked range and echoes the merge base', () => { @@ -78,7 +79,7 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }), untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } }) }) - ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE }) + ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) }) it('returns an all-zero total for a pure rename rather than omitting it', () => { @@ -88,7 +89,71 @@ describe('sumGitBranchLineTotal', () => { tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }), untracked: statsMap({}) }) - ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE }) + ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }) + }) + + it('splits out the test-file share of both halves, untracked files included', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'e2e/login.ts': { added: 4, removed: 1 } + }), + untracked: statsMap({ 'src/b.spec.ts': { added: 6 }, 'src/b.ts': { added: 2 } }) + }) + ).toEqual({ + added: 52, + removed: 8, + mergeBase: MERGE_BASE, + test: { added: 40, removed: 6 }, + generated: NO_LINES + }) + }) + + it('splits generated lines out of both the total and the test share', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'pnpm-lock.yaml': { added: 900, removed: 400 }, + 'api/service.pb.go': { added: 120, removed: 0 }, + // Generated wins the overlap: a snapshot is both, and counts as churn. + 'src/__snapshots__/a.tsx.snap': { added: 50, removed: 20 } + }), + untracked: statsMap({ 'dist/bundle.js': { added: 5000 } }) + }) + ).toEqual({ + added: 6110, + removed: 427, + mergeBase: MERGE_BASE, + test: { added: 30, removed: 5 }, + generated: { added: 6070, removed: 420 } + }) + }) + + it('keeps the three buckets disjoint so they sum back to the total', () => { + const total = sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'src/a.ts': { added: 10, removed: 2 }, + 'src/a.test.ts': { added: 30, removed: 5 }, + 'go.sum': { added: 7, removed: 3 }, + 'tests/fixtures/repo.json': { added: 4, removed: 1 } + }), + untracked: statsMap({ 'src/b.ts': { added: 2 } }) + }) + + const source = { + added: total.added - total.test!.added - total.generated!.added, + removed: total.removed - total.test!.removed - total.generated!.removed + } + expect(source).toEqual({ added: 12, removed: 2 }) + expect(total.test).toEqual({ added: 34, removed: 6 }) + expect(total.generated).toEqual({ added: 7, removed: 3 }) }) }) @@ -178,7 +243,13 @@ describe('computeGitBranchLineTotal', () => { untrackedPaths: ['new.txt'], runDiffNumstat: async () => '4\t1\tsrc/a.ts\0' }) - ).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE }) + ).resolves.toEqual({ + added: 7, + removed: 1, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + }) }) it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => { @@ -218,8 +289,8 @@ describe('computeGitBranchLineTotal', () => { release() expect(await both).toEqual([ - { added: 4, removed: 1, mergeBase: MERGE_BASE }, - { added: 4, removed: 1, mergeBase: MERGE_BASE } + { added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES }, + { added: 4, removed: 1, mergeBase: MERGE_BASE, test: NO_LINES, generated: NO_LINES } ]) expect(runDiffNumstat).toHaveBeenCalledTimes(1) }) @@ -365,7 +436,13 @@ describe('computeGitBranchLineTotal ranged-diff cooldown', () => { } } - const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE } + const TOTAL = { + added: 10, + removed: 2, + mergeBase: MERGE_BASE, + test: NO_LINES, + generated: NO_LINES + } beforeEach(() => { nowMs = 0 diff --git a/src/shared/git-branch-line-total.ts b/src/shared/git-branch-line-total.ts index 270b2870488..cfd841a46b6 100644 --- a/src/shared/git-branch-line-total.ts +++ b/src/shared/git-branch-line-total.ts @@ -5,6 +5,8 @@ import { parseNumstat, type GitLineStats } from './git-uncommitted-line-stats' +import { isGeneratedCodePath } from './generated-code-path' +import { isTestCodePath } from './test-code-path' export type { GitBranchLineTotal } @@ -87,17 +89,37 @@ export function sumGitBranchLineTotal(input: { }): GitBranchLineTotal { let added = 0 let removed = 0 - for (const stats of input.tracked.values()) { - // Binary files parse to undefined in numstat and contribute nothing, matching - // the per-file rows. - added += stats.added ?? 0 - removed += stats.removed ?? 0 + let testAdded = 0 + let testRemoved = 0 + let generatedAdded = 0 + let generatedRemoved = 0 + for (const source of [input.tracked, input.untracked]) { + for (const [filePath, stats] of source) { + // Binary files parse to undefined in numstat and contribute nothing, matching + // the per-file rows. + const fileAdded = stats.added ?? 0 + const fileRemoved = stats.removed ?? 0 + added += fileAdded + removed += fileRemoved + // Generated wins the overlap (a snapshot is both): the point of the bucket + // is to separate authored lines from churn, and a regenerated file is churn + // wherever it lives. + if (isGeneratedCodePath(filePath)) { + generatedAdded += fileAdded + generatedRemoved += fileRemoved + } else if (isTestCodePath(filePath)) { + testAdded += fileAdded + testRemoved += fileRemoved + } + } } - for (const stats of input.untracked.values()) { - added += stats.added ?? 0 - removed += stats.removed ?? 0 + return { + added, + removed, + mergeBase: input.mergeBase, + test: { added: testAdded, removed: testRemoved }, + generated: { added: generatedAdded, removed: generatedRemoved } } - return { added, removed, mergeBase: input.mergeBase } } // Why: one shared exec per (host, worktree, mergeBase). The renderer's own diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts index 7b6c6e12c81..23b57aebbc1 100644 --- a/src/shared/git-status-types.ts +++ b/src/shared/git-status-types.ts @@ -59,6 +59,10 @@ export type GitBranchLineTotal = { added: number removed: number mergeBase: string + // Path-heuristic buckets of added/removed; generated wins on overlap. Optional + // for pre-split hosts (absent = unknown, not zero). + test?: { added: number; removed: number } + generated?: { added: number; removed: number } } export type GitStatusResult = { diff --git a/src/shared/test-code-path.test.ts b/src/shared/test-code-path.test.ts new file mode 100644 index 00000000000..778158c47a7 --- /dev/null +++ b/src/shared/test-code-path.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { isTestCodePath } from './test-code-path' + +describe('isTestCodePath', () => { + it('recognizes the per-ecosystem filename conventions', () => { + for (const filePath of [ + 'src/renderer/src/components/Chip.test.tsx', + 'src/shared/git-status.spec.js', + 'internal/server/handler_test.go', + 'app/models/user_spec.rb', + 'tools/test_migration.py', + 'src/main/java/com/orca/GitTest.java', + 'src/Orca.Core/GitStatusTests.cs', + 'src/UserTest.kt', + 'AppTests.swift', + 'FooSpec.rb', + 'python/conftest.py', + 'src/components/__snapshots__/Chip.tsx.snap' + ]) { + expect(isTestCodePath(filePath), filePath).toBe(true) + } + }) + + it('recognizes test directories anywhere in the path, on either separator', () => { + expect(isTestCodePath('src/__tests__/git-status.ts')).toBe(true) + expect(isTestCodePath('tests/fixtures/repo.json')).toBe(true) + expect(isTestCodePath('e2e/login.ts')).toBe(true) + expect(isTestCodePath('src\\__mocks__\\fs.ts')).toBe(true) + }) + + it('matches whole segments, so production paths that merely contain the word do not count', () => { + for (const filePath of [ + 'src/latest/index.ts', + 'src/contest/leaderboard.ts', + 'src/testing-library-setup.ts', + 'docs/protest.md', + 'src/shared/git-status-types.ts', + // A `specs/` folder is usually specifications, not tests. + 'src/cli/specs/account.ts', + // Case-insensitive "…test.ext" suffixes hit ordinary type names. + 'src/Contest.java', + 'src/Latest.kt', + 'src/MyContest.php', + 'packages/spec.rb', + // The `test_` prefix is pytest's; outside Python it hits fixtures and pages. + 'src/pages/test_page.tsx', + 'fixtures/test_data.json' + ]) { + expect(isTestCodePath(filePath), filePath).toBe(false) + } + // ...but a real test inside one still matches on its basename. + expect(isTestCodePath('src/cli/specs/account.test.ts')).toBe(true) + expect(isTestCodePath('spec/models/user_spec.rb')).toBe(true) + }) + + it('handles a bare filename and an empty path without throwing', () => { + expect(isTestCodePath('chip.test.ts')).toBe(true) + expect(isTestCodePath('chip.ts')).toBe(false) + expect(isTestCodePath('')).toBe(false) + }) +}) diff --git a/src/shared/test-code-path.ts b/src/shared/test-code-path.ts new file mode 100644 index 00000000000..2b9a6e1b7b8 --- /dev/null +++ b/src/shared/test-code-path.ts @@ -0,0 +1,43 @@ +// Path-only test heuristic for branch line-total buckets. Conservative and +// anchored on the raw path (no lowercasing/splitting per file). + +// Whole path segments only, so `src/latest/x.ts` and `contest/` don't count. +// The trailing separator is what keeps a *file* named `test.ts` out of here. +// `spec/` is RSpec's; plural `specs/` is left out because it far more often +// holds specifications (this repo's `src/cli/specs/` is production code), and a +// real test inside one still matches on its `.spec.`/`_spec.` basename. +const TEST_DIRECTORY = + /(?:^|[/\\])(?:__mocks__|__snapshots__|__tests__|cypress|e2e|spec|tests?|testdata)[/\\]/i + +// One alternative per ecosystem convention; `[^./\\]` keeps each anchored match +// inside the basename. Joined into a single regex so V8 walks the path once. +const TEST_BASENAME = new RegExp( + `(?:${[ + /\.(?:test|spec)\.[^./\\]+/, // Chip.test.tsx, git-status.spec.js + /[-_](?:test|spec)s?\.[^./\\]+/, // handler_test.go, user_spec.rb + // `.py` only: the bare `test_` prefix is pytest's convention, and widening it + // to any extension swept in fixtures and pages (test_data.json, test_page.tsx). + // A production helper that happens to be named `test_client.py` is + // indistinguishable from a real module here — pytest collects it too. + /(?:^|[/\\])test_[^./\\]*\.py/, + /(?:^|[/\\])conftest\.py/, + /\.snap/ + ] + .map((pattern) => pattern.source) + .join('|')})$`, + 'i' +) + +// Case-sensitive on purpose: a case-insensitive `test`/`tests`/`spec` suffix +// falsely matches ordinary type names like Contest.java, Latest.kt, MyContest.php. +// JVM/C#/Swift/PHP/Ruby test types use a capital T/S in the conventional suffix. +const TEST_TYPE_NAME_SUFFIX = /(?:Test|Tests|Spec)\.(?:java|kt|kts|scala|groovy|cs|swift|php|rb)$/ + +/** Accepts POSIX and Windows separators — git reports `/`, callers may not. */ +export function isTestCodePath(filePath: string): boolean { + return ( + TEST_DIRECTORY.test(filePath) || + TEST_BASENAME.test(filePath) || + TEST_TYPE_NAME_SUFFIX.test(filePath) + ) +} From 6c9215a12750b85bee442e60d3d7dc388b77130f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:10:53 -0700 Subject: [PATCH 81/88] fix(worktrees): keep local base refresh failure toast sticky and named (#13059) * fix(worktrees): keep local base refresh failure toast sticky and named Create-time local-base refresh failures are easy to miss when the toast auto-dismisses. Stick the warning until dismiss, name the new workspace, surface the dirty owner path, and localize full detail sentences so ja/ko copy is not mid-clause English. * fix(i18n): use native commit terminology in local-base refresh toasts Address CodeRabbit feedback: replace mixed English "commits" with locale-native wording in ja/zh/ko failure detail strings, and shorten the sticky-toast comment. --- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../src/store/slices/worktrees.test.ts | 87 ++++++++++++++++- src/renderer/src/store/slices/worktrees.ts | 95 ++++++++++++++----- 7 files changed, 189 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 568e139ebed..3b986240bcf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -294,8 +294,14 @@ "34a03a6565": "Keep {{value0}} up to date", "fa9299a66f": "Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.", "14bc053a47": "Local {{value0}} was not refreshed", + "localBaseRefRefreshFailedForWorktree": "Local {{value0}} was not refreshed for \"{{value1}}\"", "4a18052018": "Local {{value0}} is behind {{value1}}", - "903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}", + "903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "Workspace \"{{value0}}\" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.", + "localBaseRefRefreshFailedDetailDirty": "The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.", + "localBaseRefRefreshFailedDetailNotFastForward": "Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.", + "localBaseRefRefreshFailedDetailError": "Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.", "0216895fb5": "Failed to delete branch", "c6cf133786": "This workspace is no longer available.", "19db0085fb": "Local branch deleted", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e60f92ba08d..f0ff1fab3dd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -239,8 +239,14 @@ "34a03a6565": "Mantener {{value0}} actualizado", "fa9299a66f": "Tu nuevo worktree está actualizado, pero el {{value0}} local está {{value1}} {{value2}} por detrás. Los diffs de IA pueden omitir commits recientes.", "14bc053a47": "No se actualizó el {{value0}} local", + "localBaseRefRefreshFailedForWorktree": "No se actualizó el {{value0}} local para \"{{value1}}\"", "4a18052018": "El {{value0}} local está {{value1}} por detrás", - "903b51c2ed": "Espacio de trabajo creado desde {{value0}}, pero Orca no pudo hacer fast-forward del {{value1}} local porque {{value2}}", + "903b51c2ed": "Espacio de trabajo creado desde {{value0}}, pero Orca no pudo hacer fast-forward del {{value1}} local. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "El espacio de trabajo \"{{value0}}\" se creó desde {{value1}}, pero Orca no pudo hacer fast-forward del {{value2}} local. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "El worktree en {{value0}} (donde está checked out el {{value1}} local) tiene cambios sin confirmar. Haz commit, stash o descarta esos cambios y luego actualiza el {{value1}} local manualmente.", + "localBaseRefRefreshFailedDetailDirty": "El worktree donde está checked out el {{value0}} local tiene cambios sin confirmar. Haz commit, stash o descarta esos cambios y luego actualiza el {{value0}} local manualmente.", + "localBaseRefRefreshFailedDetailNotFastForward": "El {{value0}} local no existe o no se puede hacer fast-forward de forma limpia desde la base remota. Revisa si hay commits solo locales antes de actualizarlo manualmente.", + "localBaseRefRefreshFailedDetailError": "Git devolvió un error al actualizar el {{value0}} local. Revisa el repo por refs bloqueados o un estado de worktree inusual y luego actualiza el {{value0}} local manualmente.", "0216895fb5": "No se pudo eliminar la rama", "19db0085fb": "Rama local eliminada", "2b0afc7f14": "No se pudo mantener actualizado el {{value0}} local", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f008abfa70e..aaa9248e68a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -239,8 +239,14 @@ "34a03a6565": "{{value0}} を最新の状態に保つ", "fa9299a66f": "新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。 AI の差分では最近の commits が失われる可能性があります。", "14bc053a47": "ローカル {{value0}} は更新されませんでした", + "localBaseRefRefreshFailedForWorktree": "「{{value1}}」のローカル {{value0}} は更新されませんでした", "4a18052018": "ローカル {{value0}} は {{value1}} の後ろにあります", - "903b51c2ed": "ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を早送りできませんでした", + "903b51c2ed": "ワークスペースは {{value0}} から作成されましたが、Orca はローカル {{value1}} を早送りできませんでした。{{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "ワークスペース「{{value0}}」は {{value1}} から作成されましたが、Orca はローカル {{value2}} を早送りできませんでした。{{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} のワークツリー(ローカル {{value1}} がチェックアウトされている場所)に未コミットの変更があります。コミット、stash、または破棄してから、ローカル {{value1}} を手動で更新してください。", + "localBaseRefRefreshFailedDetailDirty": "ローカル {{value0}} がチェックアウトされているワークツリーに未コミットの変更があります。コミット、stash、または破棄してから、ローカル {{value0}} を手動で更新してください。", + "localBaseRefRefreshFailedDetailNotFastForward": "ローカル {{value0}} が存在しないか、リモートのベースからクリーンに早送りできません。手動で更新する前に、ローカルにのみ存在するコミットがないか確認してください。", + "localBaseRefRefreshFailedDetailError": "ローカル {{value0}} の更新中に Git がエラーを返しました。ロックされた refs や異常なワークツリー状態がないか確認し、ローカル {{value0}} を手動で更新してください。", "0216895fb5": "ブランチの削除に失敗しました", "19db0085fb": "ローカルブランチが削除されました", "2b0afc7f14": "ローカル {{value0}} を最新の状態に維持できませんでした", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1b4c5c4fba2..f462a445800 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -239,8 +239,14 @@ "34a03a6565": "{{value0}}을(를) 최신 상태로 유지하세요", "fa9299a66f": "새 워크트리는 최신이지만 로컬 {{value0}}은(는) {{value1}} {{value2}} 뒤처져 있습니다. AI diff가 최근 commits을 놓칠 수 있습니다.", "14bc053a47": "로컬 {{value0}}이(가) 새로 고쳐지지 않았습니다.", + "localBaseRefRefreshFailedForWorktree": "\"{{value1}}\"의 로컬 {{value0}}이(가) 새로 고쳐지지 않았습니다.", "4a18052018": "로컬 {{value0}}이(가) {{value1}} 뒤에 있습니다.", - "903b51c2ed": "{{value0}}에서 워크스페이스를 만들었지만 {{value2}} 때문에 Orca가 로컬 {{value1}}을(를) fast-forward할 수 없습니다.", + "903b51c2ed": "{{value0}}에서 워크스페이스를 만들었지만 Orca가 로컬 {{value1}}을(를) fast-forward할 수 없습니다. {{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "워크스페이스 \"{{value0}}\"을(를) {{value1}}에서 만들었지만 Orca가 로컬 {{value2}}을(를) fast-forward할 수 없습니다. {{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} 워크트리(로컬 {{value1}}이(가) 체크아웃된 위치)에 커밋되지 않은 변경이 있습니다. 커밋, stash 또는 삭제한 다음 로컬 {{value1}}을(를) 수동으로 업데이트하세요.", + "localBaseRefRefreshFailedDetailDirty": "로컬 {{value0}}이(가) 체크아웃된 워크트리에 커밋되지 않은 변경이 있습니다. 커밋, stash 또는 삭제한 다음 로컬 {{value0}}을(를) 수동으로 업데이트하세요.", + "localBaseRefRefreshFailedDetailNotFastForward": "로컬 {{value0}}이(가) 없거나 원격 베이스에서 깨끗하게 fast-forward할 수 없습니다. 수동으로 업데이트하기 전에 로컬 전용 커밋이 있는지 확인하세요.", + "localBaseRefRefreshFailedDetailError": "로컬 {{value0}}을(를) 업데이트하는 중 Git이 오류를 반환했습니다. 잠긴 refs나 비정상적인 워크트리 상태가 있는지 확인한 다음 로컬 {{value0}}을(를) 수동으로 업데이트하세요.", "0216895fb5": "브랜치를 삭제하지 못했습니다.", "19db0085fb": "로컬 브랜치가 삭제되었습니다.", "2b0afc7f14": "로컬 {{value0}}을(를) 최신 상태로 유지할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 90628fb006f..50700ac525e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -239,8 +239,14 @@ "34a03a6565": "保持 {{value0}} 为最新状态", "fa9299a66f": "您的新工作树是最新的,但本地 {{value0}} 落后于 {{value1}} {{value2}} 。 AI 差异可能会错过最近的 commits。", "14bc053a47": "本地 {{value0}} 未刷新", + "localBaseRefRefreshFailedForWorktree": "“{{value1}}”的本地 {{value0}} 未刷新", "4a18052018": "本地 {{value0}} 落后于 {{value1}}", - "903b51c2ed": "从 {{value0}} 创建工作区,但 Orca 无法快进本地 {{value1}},因为 {{value2}}", + "903b51c2ed": "从 {{value0}} 创建工作区,但 Orca 无法快进本地 {{value1}}。{{value2}}", + "localBaseRefRefreshFailedDescriptionNamed": "工作区“{{value0}}”从 {{value1}} 创建,但 Orca 无法快进本地 {{value2}}。{{value3}}", + "localBaseRefRefreshFailedDetailDirtyNamed": "{{value0}} 处的工作树(本地 {{value1}} 的检出位置)有未提交的更改。请提交、暂存或丢弃这些更改,然后手动更新本地 {{value1}}。", + "localBaseRefRefreshFailedDetailDirty": "检出本地 {{value0}} 的工作树有未提交的更改。请提交、暂存或丢弃这些更改,然后手动更新本地 {{value0}}。", + "localBaseRefRefreshFailedDetailNotFastForward": "本地 {{value0}} 不存在,或无法从远程基线干净快进。手动更新前请检查是否有仅存在于本地的提交。", + "localBaseRefRefreshFailedDetailError": "更新本地 {{value0}} 时 Git 返回错误。请检查仓库是否有锁定的 refs 或异常工作树状态,然后手动更新本地 {{value0}}。", "0216895fb5": "删除分支失败", "19db0085fb": "本地分支已删除", "2b0afc7f14": "无法使本地 {{value0}} 保持最新", diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 785ada2fd09..b13a197b597 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4725,7 +4725,8 @@ describe('createWorktree base status merge', () => { const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', - path: '/path/wt1' + path: '/path/wt1', + displayName: 'feature-wt' }) mockApi.worktrees.create.mockResolvedValue({ worktree: wt, @@ -4739,12 +4740,78 @@ describe('createWorktree base status merge', () => { await store.getState().createWorktree('repo1', 'feature', 'origin/main') - expect(toast.warning).toHaveBeenCalledWith('Local main was not refreshed', { - description: expect.stringContaining(expectedReason) + expect(toast.warning).toHaveBeenCalledWith('Local main was not refreshed for "feature-wt"', { + id: 'local-base-ref-refresh-failed:repo1::/path/wt1:main', + description: expect.stringContaining(expectedReason), + duration: Infinity, + dismissible: true }) const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('feature-wt') + // Create already succeeded — guidance must push manual recovery, not "try again". + expect(description).toMatch(/manually/i) + expect(description).not.toContain('try again') expect(description).not.toContain('AI tools') expect(description).not.toContain('git diff') + // Owner path is only meaningful for the dirty-owner skip; do not leak it into other reasons. + if (status === 'skipped_dirty_worktree') { + expect(description).toContain('/repo') + } else { + expect(description).not.toContain('/repo') + } + }) + + it('falls back to the generic dirty detail when ownerWorktreePath is missing', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + displayName: 'feature-wt' + }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + localBaseRefRefresh: { + status: 'skipped_dirty_worktree', + baseRef: 'origin/main', + localBranch: 'main' + } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + const description = vi.mocked(toast.warning).mock.calls.at(-1)?.[1]?.description + expect(description).toContain('uncommitted changes') + expect(description).toContain('where local main is checked out') + expect(description).not.toContain('The worktree at') + }) + + it('names the toast from branch when displayName is blank', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/feature', + displayName: ' ' + }) + mockApi.worktrees.create.mockResolvedValue({ + worktree: wt, + localBaseRefRefresh: { + status: 'skipped_error', + baseRef: 'origin/main', + localBranch: 'main' + } + }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).toHaveBeenCalledWith( + 'Local main was not refreshed for "feature"', + expect.objectContaining({ + id: 'local-base-ref-refresh-failed:repo1::/path/wt1:main' + }) + ) }) it('does not warn when the local base ref refresh succeeds', async () => { @@ -4768,6 +4835,20 @@ describe('createWorktree base status merge', () => { expect(toast.warning).not.toHaveBeenCalled() }) + it('does not warn when local base ref refresh is omitted from the create result', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1' + }) + mockApi.worktrees.create.mockResolvedValue({ worktree: wt }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(toast.warning).not.toHaveBeenCalled() + }) + it('suggests turning on local main freshness when the create result reports a stale local base', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9b639b02979..f2efca80185 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -77,6 +77,7 @@ import { import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -232,37 +233,81 @@ function shouldDeferActivationTerminalPrep(): boolean { return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' } -function showLocalBaseRefRefreshToast(result: LocalBaseRefRefreshResult | undefined): void { +function localBaseRefRefreshFailureDetail(result: LocalBaseRefRefreshResult): string { + const ownerWorktreePath = result.ownerWorktreePath?.trim() + switch (result.status) { + case 'skipped_dirty_worktree': + // Create already succeeded — guide cleanup + manual base update, not "try again". + return ownerWorktreePath + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirtyNamed', + 'The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.', + { value0: ownerWorktreePath, value1: result.localBranch } + ) + : translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirty', + 'The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'skipped_not_fast_forward': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailNotFastForward', + 'Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.', + { value0: result.localBranch } + ) + case 'skipped_error': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailError', + 'Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'updated': + return '' + } +} + +function showLocalBaseRefRefreshToast( + result: LocalBaseRefRefreshResult | undefined, + createdWorktree?: Pick +): void { if (!result || result.status === 'updated') { return } - let reason: string - switch (result.status) { - case 'skipped_dirty_worktree': - reason = - 'the worktree where it is checked out has uncommitted changes. Commit, stash, or discard those changes, then try again.' - break - case 'skipped_not_fast_forward': - reason = - 'the local branch does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.' - break - case 'skipped_error': - reason = - 'Git returned an error while updating the local ref. Check the repo for locked refs or unusual worktree state, then try again.' - break - } + const worktreeName = createdWorktree ? resolveWorktreeDisplayName(createdWorktree).trim() : '' + const detail = localBaseRefRefreshFailureDetail(result) + // Why: Infinity so create-time failures aren't buried; id is per worktree so each create stays attributable. toast.warning( - translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { - value0: result.localBranch - }), + worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedForWorktree', + 'Local {{value0}} was not refreshed for "{{value1}}"', + { value0: result.localBranch, value1: worktreeName } + ) + : translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { + value0: result.localBranch + }), { - description: translate( - 'auto.store.slices.worktrees.903b51c2ed', - 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}', - { value0: result.baseRef, value1: result.localBranch, value2: reason } - ) + id: `local-base-ref-refresh-failed:${createdWorktree?.id ?? 'unknown'}:${result.localBranch}`, + description: worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDescriptionNamed', + 'Workspace "{{value0}}" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}', + { + value0: worktreeName, + value1: result.baseRef, + value2: result.localBranch, + value3: detail + } + ) + : translate( + 'auto.store.slices.worktrees.903b51c2ed', + 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}', + { value0: result.baseRef, value1: result.localBranch, value2: detail } + ), + duration: Infinity, + dismissible: true } ) } @@ -4109,7 +4154,7 @@ export const createWorktreeSlice: StateCreator sortEpoch: s.sortEpoch + 1 } }) - showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree) if (result.baseFallback) { requestWorktreeBaseFallbackNotice(result.baseFallback) } From bc1e049b3f1725adc2aacb9eba6611cfb76e637f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:14:37 -0700 Subject: [PATCH 82/88] fix(terminal): defer metric option writes to unmeasurable panes (#12944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): defer metric option writes to unmeasurable panes Writing fontSize/fontFamily/fontWeight/lineHeight makes xterm re-measure cell size against the pane's current box. A hidden or mid-layout pane can measure a wrong-but-nonzero size, which latches (hasValidSize) and mis-keys the shared WebGL glyph atlas until a manual resize — the stuck variant of the P0 bold/blurry-font reports. Metric writes now land only on measurable panes; otherwise the latest values park per-pane and flush on the next safe fit or reveal (with a refit on the light tab-resume path, which otherwise skips fitting). Measurability helpers move to pane-fit-measurability.ts to stay under the pane-fit.ts line cap. * fix(terminal): key metric deferral by terminal, not pane view getPanes() returns a fresh toPublicPane() wrapper per call, so a WeakMap keyed on ManagedPane never matched across call sites: deferred metric options were dropped, not deferred. Key on pane.terminal, which is carried by reference and dies with the pane. Also from review: - flushDeferredPaneMetricOptionsIfMeasurable checks the pending WeakMap before the measurability probe, so the common no-deferral case costs zero forced style/layout on every reveal. - applyTerminalAppearance skips the apply (and the probe) when all five values are already live and nothing is parked; any settings write re-runs the pass over every mounted pane, and arming a no-op deferral would trigger a refit on the next reveal. - fitRevealedPane flushes first: its pixel/grid checks can both no-op and return without fitting, stranding parked options. - Font zoom folds its direct fontSize write into any pending deferral so the flush inside safeFit cannot clobber the user's zoom. Corrects comments that asserted a cell-size re-measure mechanism xterm does not have: CharSizeService measures via OffscreenCanvas TextMetrics, independent of the pane box, and only fontSize/fontFamily re-measure. Test fixtures now allocate a fresh pane view per getPanes() call, which is what production does and what hid the keying bug. * fix(terminal): re-check the fit floor after a metric flush performSafeFit evaluated the min cols/rows gate with the pre-flush cell size, then flushed and fit unconditionally. A large font jump on a narrow pane passes the gate at the old size and lands under it at the new one, so fit() pinned the PTY to the tiny grid the floor exists to reject. Re-check after a flush that actually landed. The parked values still apply, so the pane is never stuck on stale metrics; only the fit is skipped. * fix(terminal): route a reveal metric flush through the stable fit fitRevealedPane's new flush branch called safeFit directly, which is exactly what the function's contract forbids on reveal: resumeRendering has just re-attached WebGL, whose cell metrics transiently differ from the DOM renderer's, so a raw fit can propose a one-column-off grid and reflow — and xterm's wrap/unwrap is not a perfect inverse, leaving a diff-painting inline TUI corrupted. A landed flush leaves pixels unchanged with a diverged grid, the same shape as a snapshot resize, so it takes the same steady-grid repair. A real resize still fits synchronously, after the flush. Reachable via window wake, which calls fitAllRevealedPanes with no pre-flush loop. * fix(terminal): gate metric writes on the pixel box, not the fit floor canApplyPaneMetricOptions reused canMeasurePaneForFit, whose >=8 cols / >=4 rows floor exists to stop a fit pinning the PTY to a sliver. But the divider clamp is 50px, which clears the 48px pixel floor and proposes ~5 cols — so a pane dragged to the clamp deferred every font change and never flushed: it never hides, and its box never changes, so no reveal and no ResizeObserver entry ever arrives. It rendered a stale font until widened, where pre-PR the write was unconditional. Gate metric writes on display plus the pixel box only. Hidden panes and the transient worktree-switch overlay are near-zero, so they still defer — the deferral's purpose is unchanged. The cols/rows floor stays on the fit, including the post-flush re-check in performSafeFit. Apply and flush share the same predicate, so no "applies but never flushes" state can open up. * fix(terminal): flush heavy reveal metrics after WebGL resume --- .oxlintrc.json | 7 +- .../scripts/relay-watcher-fault-harness.mjs | 22 ++- .../GitHubMarkdownComposerEditorPane.tsx | 5 +- .../terminal-pane/terminal-appearance.test.ts | 91 +++++++++- .../terminal-pane/terminal-appearance.ts | 25 ++- .../terminal-visibility-resume.test.ts | 29 +++ .../terminal-visibility-resume.ts | 14 ++ .../terminal-pane/useTerminalFontZoom.ts | 5 + .../pane-manager/pane-fit-measurability.ts | 63 +++++++ .../src/lib/pane-manager/pane-fit.test.ts | 40 +++++ src/renderer/src/lib/pane-manager/pane-fit.ts | 46 ++--- .../pane-metric-options-deferral.test.ts | 167 ++++++++++++++++++ .../pane-metric-options-deferral.ts | 113 ++++++++++++ .../lib/pane-manager/pane-reveal-fit.test.ts | 44 ++++- .../src/lib/pane-manager/pane-reveal-fit.ts | 9 +- ...script-prompt-unreadable-orca-yaml.spec.ts | 5 +- 16 files changed, 623 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-fit-measurability.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
{ }) describe('applyTerminalAppearance theme assignment', () => { // xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated. - function makePane(id: number): ManagedPane { - return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + // Measurable by default: metric options (fontSize/fontFamily/…) only land on + // panes that can measure; unmeasurable panes defer them until fit/reveal. + function makePane(id: number, overrides?: { measurable?: boolean }): ManagedPane { + const measurable = overrides?.measurable ?? true + return { + id, + terminal: { options: {}, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane } function makeManager(panes: ManagedPane[]): PaneManager { return { - getPanes: () => panes, + // Mirrors the real getPanes(), which allocates a fresh toPublicPane() + // wrapper per call over a shared terminal — per-pane state must survive that. + getPanes: () => panes.map((pane) => ({ ...pane })), setPaneLigaturesEnabled: vi.fn(), setPaneStyleOptions: vi.fn() } as unknown as PaneManager @@ -265,6 +281,75 @@ describe('applyTerminalAppearance theme assignment', () => { // The value-gate must not rewrite an unchanged ratio — each write clears xterm's contrast cache. expect(writes).toBe(writesAfterFirst) }) + + it('defers metric options on an unmeasurable pane and lands them on the next fit', () => { + // A metric write makes xterm clear, resize and full-refresh; on a pane with + // no usable box that repaint is wasted and the cols/rows re-fit that must + // follow it cannot run. The write waits for a measurable pane. + let measurable = false + const pane = { + id: 1, + terminal: { options: {}, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, terminalFontSize: 19 }) + + expect(pane.terminal.options.fontSize).toBeUndefined() + expect(pane.terminal.options.fontFamily).toBeUndefined() + // Non-metric options are safe while hidden and must not be deferred with them. + expect(pane.terminal.options.cursorStyle).toBeDefined() + + measurable = true + safeFit(pane) + + expect(pane.terminal.options.fontSize).toBe(19) + expect(pane.terminal.options.fontFamily).toContain('monospace') + }) + + it('applies only the latest deferred metric options after repeated hidden changes', () => { + let measurable = false + const writes: number[] = [] + const options: Record = {} + Object.defineProperty(options, 'fontSize', { + configurable: true, + enumerable: true, + get: () => writes.at(-1), + set: (value: number) => { + writes.push(value) + } + }) + const pane = { + id: 1, + terminal: { options, cols: 80, rows: 24 }, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: measurable ? 800 : 0, height: measurable ? 600 : 0 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: () => (measurable ? { cols: 80, rows: 24 } : undefined) + } + } as unknown as ManagedPane + const settings = getDefaultSettings('/tmp') + + apply(pane, { ...settings, terminalFontSize: 15 }) + apply(pane, { ...settings, terminalFontSize: 21 }) + + measurable = true + safeFit(pane) + + // Latest wins, exactly one write: intermediate hidden values never touch xterm. + expect(writes).toEqual([21]) + }) }) describe('publishTerminalViewAttributesAtAppStart', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index c1ff2c24f1a..f9dbd9a1a0e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,11 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { canApplyPaneMetricOptions } from '@/lib/pane-manager/pane-fit' +import { + applyOrDeferPaneMetricOptions, + paneMetricOptionsAlreadySettled +} from '@/lib/pane-manager/pane-metric-options-deferral' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -174,10 +179,21 @@ export function applyTerminalAppearance( pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle) pane.terminal.options.cursorBlink = settings.terminalCursorBlink const paneSize = paneFontSizes.get(pane.id) - pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize - pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily) - pane.terminal.options.fontWeight = terminalFontWeights.fontWeight - pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold + const metricOptions = { + fontSize: paneSize ?? settings.terminalFontSize, + fontFamily: buildFontFamily(settings.terminalFontFamily), + fontWeight: terminalFontWeights.fontWeight, + fontWeightBold: terminalFontWeights.fontWeightBold, + lineHeight: normalizeTerminalLineHeight(settings.terminalLineHeight) + } + // Why value-gated: any settings write re-runs this over every mounted pane, and + // canApplyPaneMetricOptions forces style+layout; an unchanged no-op deferral + // would also arm a pointless refit on the next reveal. + // Why deferred: a metric write makes xterm clear/resize/full-refresh, which is + // wasted on a pane with no usable box and whose follow-up cols/rows fit can't run. + if (!paneMetricOptionsAlreadySettled(pane, metricOptions)) { + applyOrDeferPaneMetricOptions(pane, metricOptions, canApplyPaneMetricOptions(pane)) + } pane.terminal.options.scrollSensitivity = normalizeTerminalScrollSensitivity( settings.terminalScrollSensitivity ) @@ -186,7 +202,6 @@ export function applyTerminalAppearance( ) // Why only 'true': 'left'/'right' are handled in the keydown policy, which needs Option composable at the xterm level. pane.terminal.options.macOptionIsMeta = effectiveMacOptionAsAlt === 'true' - pane.terminal.options.lineHeight = normalizeTerminalLineHeight(settings.terminalLineHeight) // Why unconditional: the helper no-ops when addon state already matches, so this keeps new panes and live toggles in sync. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) const transport = paneTransports.get(pane.id) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index e2d4aac2929..c6b82f2470c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -27,6 +27,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) +const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) +vi.mock('@/lib/pane-manager/pane-fit', () => ({ + flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) => + flushDeferredPaneMetricOptionsIfMeasurable(pane) +})) const resetTerminalLinkifierHoverState = vi.fn() const isTerminalLinkifierHoverActive = vi.fn((_terminal: unknown) => false) vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ @@ -131,6 +136,17 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('leaves heavy metric flushing to the reveal fit after rendering resumes', () => { + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: {} }]) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(manager.fitAllRevealedPanes).toHaveBeenCalledTimes(1) + expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled() + }) + it('does not fit on a light tab reveal', () => { const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) @@ -139,6 +155,19 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('flushes hidden-era metric options on reveal and refits the light path', () => { + // A font change while hidden must land and refit on reveal, or cols/rows + // stay pinned to the old metrics. + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: {} }]) + flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValueOnce(true) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledTimes(1) + expect(manager.fitAllRevealedPanes).toHaveBeenCalledTimes(1) + }) + it('fits window wake recovery through the stable path, not the sync fit', () => { const manager = createManager() recoverVisibleTerminalWindowWake({ diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index c9cf2a8926d..01d3627d2c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -15,6 +15,7 @@ import { } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' +import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -72,6 +73,12 @@ export function resumeTerminalVisibility({ captureViewportPositions(!wasVisible) withSuppressedScrollTracking(() => { if (shouldUseLightTabResume) { + let flushedDeferredMetrics = false + for (const pane of manager.getPanes()) { + if (flushDeferredPaneMetricOptionsIfMeasurable(pane)) { + flushedDeferredMetrics = true + } + } // Why: intra-worktree tab switches only toggle the overlay. Keeping // synchronous drain and atlas rebuilds off this path avoids racing the // overlay's delayed geometry fit. Still request hidden-output recovery: @@ -81,10 +88,17 @@ export function resumeTerminalVisibility({ // — a background agent streaming in another pane must not defer this tab's // atlas rebuild. scheduleTabRevealWebglAtlasRecovery() + if (flushedDeferredMetrics) { + // Why: the light path normally skips fitting, but flushed metrics changed + // cell size — refit so cols/rows match before the overlay settles. + manager.fitAllRevealedPanes() + } if (isActive) { focusActivePane(manager) } } else { + // fitAllRevealedPanes flushes after WebGL reattaches, avoiding a redundant + // full refresh in the suspended DOM renderer while preserving first paint. resumeTerminalVisibilityHeavy(manager, isActive) } enforceTerminalViewportIntents(manager) diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index 1d2bd6c22a0..d53cda92612 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { overridePendingPaneMetricOptions } from '@/lib/pane-manager/pane-metric-options-deferral' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,6 +58,10 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize + // Why: safeFit flushes parked metric options, which would otherwise + // overwrite this zoom with the font size captured while the pane was + // unmeasurable. Fold the new size in; other parked keys still apply. + overridePendingPaneMetricOptions(pane, { fontSize: nextSize }) safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts b/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts new file mode 100644 index 00000000000..57186374409 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-measurability.ts @@ -0,0 +1,63 @@ +import type { ManagedPane } from './pane-manager-types' +import { isManagedPaneDisplayNone } from './pane-display-visibility' +import { + flushDeferredPaneMetricOptions, + hasDeferredPaneMetricOptions +} from './pane-metric-options-deferral' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export function getProposedPaneDimensions( + pane: ManagedPane +): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function hasPaneFitPixelBox(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure !== 'function') { + return true + } + const rect = measure.call(pane.container) + return rect.width >= MIN_PANE_FIT_WIDTH_PX && rect.height >= MIN_PANE_FIT_HEIGHT_PX +} + +export function canMeasurePaneForFit(pane: ManagedPane): boolean { + if (!hasPaneFitPixelBox(pane)) { + return false + } + const dims = getProposedPaneDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +/** Why only the pixel box, not the fit floor: a pane held at the 50px divider + * clamp proposes ~5 cols, so the fit floor would reject it forever — it never + * hides and its box never changes, so nothing would flush and it would render + * a stale font until widened. A hidden pane or the transient worktree-switch + * overlay is near-zero, so the pixel floor still defers those. The cols/rows + * floor stays where it belongs: on the fit. */ +export function canApplyPaneMetricOptions(pane: ManagedPane): boolean { + return !isManagedPaneDisplayNone(pane) && hasPaneFitPixelBox(pane) +} + +/** Why the pending check comes first: it is an O(1) WeakMap lookup, while the + * measurability probe forces style+layout. This runs per pane on every reveal, + * so the common no-deferral case must cost zero DOM reads. */ +export function flushDeferredPaneMetricOptionsIfMeasurable(pane: ManagedPane): boolean { + if (!hasDeferredPaneMetricOptions(pane) || !canApplyPaneMetricOptions(pane)) { + return false + } + return flushDeferredPaneMetricOptions(pane) +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index e7975eec142..1e0a9cf92bb 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' import { safeFit, safeFitAndThen } from './pane-fit' +import { applyOrDeferPaneMetricOptions } from './pane-metric-options-deferral' import { paneFitClientSizeChanged } from './pane-reveal-fit' vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ @@ -321,3 +322,42 @@ describe('paneFitClientSizeChanged (reveal fit gate)', () => { expect(paneFitClientSizeChanged(pane)).toBe(true) }) }) + +describe('deferred metric flush inside safeFit', () => { + function createMetricPane(): ManagedPane & { fitAddon: { fit: ReturnType } } { + const terminal = { cols: 80, rows: 24, options: {} as Record } + // Grid shrinks once the parked large font lands — the case the min-dimension + // gate exists to reject, but which it can only see after the flush. + const proposeDimensions = (): { cols: number; rows: number } => + Number(terminal.options.fontSize ?? 10) >= 24 ? { cols: 5, rows: 2 } : { cols: 40, rows: 20 } + return { + id: 11, + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 340, height: 240 }) + }, + fitAddon: { fit: vi.fn(), proposeDimensions: vi.fn(proposeDimensions) } + } as unknown as ManagedPane & { fitAddon: { fit: ReturnType } } + } + + it('does not fit when the flushed font drops the pane under the minimum grid', () => { + const pane = createMetricPane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 24 }, false) + + expect(safeFit(pane)).toBe(false) + // The parked value still lands so the pane is not stuck on stale metrics. + expect(pane.terminal.options.fontSize).toBe(24) + // But the PTY must not be pinned to the 5x2 grid the floor rejects. + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('still fits when the flushed font keeps the pane above the minimum grid', () => { + const pane = createMetricPane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, false) + + expect(safeFit(pane)).toBe(true) + expect(pane.terminal.options.fontSize).toBe(12) + expect(pane.fitAddon.fit).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index dcb638c0a1a..0b7d3bd8e92 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -21,13 +21,16 @@ import { deferTerminalGeometryMutationDuringRebuild, isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { flushDeferredPaneMetricOptions } from './pane-metric-options-deferral' +import { canMeasurePaneForFit, getProposedPaneDimensions } from './pane-fit-measurability' import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' import { recordPaneFitClientSize } from './pane-fit-client-size' -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 +export { + canApplyPaneMetricOptions, + canMeasurePaneForFit, + flushDeferredPaneMetricOptionsIfMeasurable +} from './pane-fit-measurability' export type SafeFitContinuationHandle = { completion: Promise @@ -45,33 +48,8 @@ const pendingSafeFitContinuations = new WeakMap< Map >() -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - export { readFitClientSize } from './pane-fit-client-size' -export function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container?.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { // Why: split reparent has its own delayed restore; restoring here can fight that timer. return !( @@ -86,6 +64,14 @@ function performSafeFit(pane: ManagedPane): boolean { if (!canMeasurePaneForFit(pane)) { return false } + // Why here: metric options deferred while the pane was unmeasurable must land + // before this fit reads dimensions, or the fit pins cols/rows to stale metrics. + // Why re-check: the gate above measured with the old cell size — a large font + // jump on a narrow pane can drop it under the floor, and fit() would then pin + // the PTY at the tiny grid that floor exists to prevent. + if (flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { + return false + } let scrollIntent = null as ReturnType let pinnedScrollState: ScrollState | null = null let shouldRestoreScroll = false @@ -113,7 +99,7 @@ function performSafeFit(pane: ManagedPane): boolean { return true } - const dims = getProposedDimensions(pane) + const dims = getProposedPaneDimensions(pane) if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. resumePendingFitScrollRestoreAfterFit(pane.terminal) diff --git a/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts new file mode 100644 index 00000000000..e07ae670d18 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' +import { toPublicPane } from './pane-public-view' +import { canApplyPaneMetricOptions, canMeasurePaneForFit } from './pane-fit-measurability' +import { + applyOrDeferPaneMetricOptions, + flushDeferredPaneMetricOptions, + hasDeferredPaneMetricOptions, + overridePendingPaneMetricOptions, + paneMetricOptionsAlreadySettled +} from './pane-metric-options-deferral' + +function makePane(): ManagedPane { + return { id: 1, terminal: { options: {} } } as unknown as ManagedPane +} + +// Mirrors PaneManager.getPanes(), which returns a fresh toPublicPane() wrapper +// per call — so deferral state must not be keyed on the pane object identity. +function makeInternalPane(): ManagedPaneInternal { + return { id: 1, terminal: { options: {} } } as unknown as ManagedPaneInternal +} + +describe('pane-metric-options-deferral', () => { + it('writes metric options directly when the pane is measurable', () => { + const pane = makePane() + + const result = applyOrDeferPaneMetricOptions(pane, { fontSize: 16, fontFamily: 'X' }, true) + + expect(result).toBe('applied') + expect(pane.terminal.options.fontSize).toBe(16) + expect(pane.terminal.options.fontFamily).toBe('X') + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('defers writes on an unmeasurable pane until flushed', () => { + const pane = makePane() + + const result = applyOrDeferPaneMetricOptions(pane, { fontSize: 16 }, false) + + expect(result).toBe('deferred') + expect(pane.terminal.options.fontSize).toBeUndefined() + expect(hasDeferredPaneMetricOptions(pane)).toBe(true) + + expect(flushDeferredPaneMetricOptions(pane)).toBe(true) + expect(pane.terminal.options.fontSize).toBe(16) + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('keeps only the latest deferral and clears it when a measurable apply lands', () => { + const pane = makePane() + + applyOrDeferPaneMetricOptions(pane, { fontSize: 15 }, false) + applyOrDeferPaneMetricOptions(pane, { fontSize: 21 }, false) + // A later measurable apply supersedes the pending deferral entirely: flushing + // afterwards must not resurrect the hidden-era value. + applyOrDeferPaneMetricOptions(pane, { fontSize: 18 }, true) + + expect(pane.terminal.options.fontSize).toBe(18) + expect(flushDeferredPaneMetricOptions(pane)).toBe(false) + expect(pane.terminal.options.fontSize).toBe(18) + }) + + it('writes only the provided keys', () => { + const pane = makePane() + pane.terminal.options.lineHeight = 1.4 + + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, true) + + expect(pane.terminal.options.lineHeight).toBe(1.4) + expect(pane.terminal.options.fontWeight).toBeUndefined() + }) + + it('flush is a no-op without a pending deferral', () => { + const pane = makePane() + expect(flushDeferredPaneMetricOptions(pane)).toBe(false) + }) + + it('flushes through a different pane view than the one that deferred', () => { + const internal = makeInternalPane() + const deferView = toPublicPane(internal) + const flushView = toPublicPane(internal) + // Every getPanes() call allocates a new wrapper; the two views are the same + // pane but never the same object. + expect(deferView).not.toBe(flushView) + + applyOrDeferPaneMetricOptions(deferView, { fontSize: 16 }, false) + + expect(hasDeferredPaneMetricOptions(flushView)).toBe(true) + expect(flushDeferredPaneMetricOptions(flushView)).toBe(true) + expect(internal.terminal.options.fontSize).toBe(16) + }) + + it('flushes through the internal pane when a public view deferred', () => { + // fitAllPanesInternal / fitAllRevealedPanes iterate the internal panes. + const internal = makeInternalPane() + + applyOrDeferPaneMetricOptions(toPublicPane(internal), { fontSize: 17 }, false) + + expect(flushDeferredPaneMetricOptions(internal)).toBe(true) + expect(internal.terminal.options.fontSize).toBe(17) + }) + + it('reports settled only when every value is live and nothing is parked', () => { + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 14, lineHeight: 1.2 }, true) + + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 14, lineHeight: 1.2 })).toBe(true) + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 15, lineHeight: 1.2 })).toBe(false) + }) + + it('is never settled while a deferral is parked, even if values match', () => { + // Otherwise an equal-valued apply would skip and strand the stale deferral. + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 20 }, false) + pane.terminal.options.fontSize = 14 + + expect(paneMetricOptionsAlreadySettled(pane, { fontSize: 14 })).toBe(false) + }) + + it('folds a direct write into a pending deferral so the flush cannot clobber it', () => { + const pane = makePane() + applyOrDeferPaneMetricOptions(pane, { fontSize: 12, lineHeight: 1.5 }, false) + + // Font zoom writes fontSize directly, then fits — which flushes. + pane.terminal.options.fontSize = 22 + overridePendingPaneMetricOptions(pane, { fontSize: 22 }) + flushDeferredPaneMetricOptions(pane) + + expect(pane.terminal.options.fontSize).toBe(22) + // The other parked key still lands. + expect(pane.terminal.options.lineHeight).toBe(1.5) + }) + + it('override is a no-op when nothing is parked', () => { + const pane = makePane() + overridePendingPaneMetricOptions(pane, { fontSize: 22 }) + + expect(hasDeferredPaneMetricOptions(pane)).toBe(false) + expect(pane.terminal.options.fontSize).toBeUndefined() + }) +}) + +describe('canApplyPaneMetricOptions gating', () => { + function makeSizedPane(rect: { width: number; height: number }, cols: number): ManagedPane { + return { + id: 2, + terminal: { options: {} }, + container: { getBoundingClientRect: () => rect }, + fitAddon: { proposeDimensions: () => ({ cols, rows: 20 }) } + } as unknown as ManagedPane + } + + it('applies to a pane clamped narrow by a divider drag', () => { + // 50px is the divider clamp: over the pixel floor but ~5 cols, under the + // fit floor. Gating on the fit floor would strand it on a stale font, + // because it never hides and its box never changes. + expect(canApplyPaneMetricOptions(makeSizedPane({ width: 50, height: 600 }, 5))).toBe(true) + }) + + it('still defers on a near-zero box (hidden pane / worktree-switch overlay)', () => { + expect(canApplyPaneMetricOptions(makeSizedPane({ width: 0, height: 0 }, 0))).toBe(false) + }) + + it('leaves the cols/rows floor on the fit itself', () => { + expect(canMeasurePaneForFit(makeSizedPane({ width: 50, height: 600 }, 5))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts new file mode 100644 index 00000000000..fc4b32312db --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-metric-options-deferral.ts @@ -0,0 +1,113 @@ +import type { ManagedPane } from './pane-manager-types' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' + +/** The xterm options whose writes make the renderer clear, resize and full-refresh. */ +export type PaneMetricOptions = { + fontSize?: number + fontFamily?: string + fontWeight?: string | number + fontWeightBold?: string | number + lineHeight?: number +} + +type PaneTerminal = ManagedPane['terminal'] + +// Why keyed on the terminal, not the pane: getPanes() hands out a fresh +// toPublicPane() wrapper per call, so a pane-keyed entry could never be found +// again. `terminal` is carried by reference and dies with the pane. +const deferredMetricOptions = new WeakMap() + +/** + * Why deferred: writing one of these makes xterm clear the renderer, re-resize + * to the current grid and full-refresh. On a pane with no usable box that + * repaint is wasted — it lands in the DOM-renderer fallback while WebGL is + * suspended — and the cols/rows re-fit that has to follow the write cannot run. + * So the values park here and land once the pane can actually be fitted. + */ +export function applyOrDeferPaneMetricOptions( + pane: ManagedPane, + options: PaneMetricOptions, + measurable: boolean +): 'applied' | 'deferred' { + if (!measurable) { + // Latest wins: a newer settings change while hidden supersedes the pending one. + deferredMetricOptions.set(pane.terminal, options) + return 'deferred' + } + deferredMetricOptions.delete(pane.terminal) + writePaneMetricOptions(pane, options) + return 'applied' +} + +/** Applies a pending deferral. Callers must ensure the pane is measurable. */ +export function flushDeferredPaneMetricOptions(pane: ManagedPane): boolean { + const pending = deferredMetricOptions.get(pane.terminal) + if (!pending) { + return false + } + deferredMetricOptions.delete(pane.terminal) + writePaneMetricOptions(pane, pending) + recordTerminalWebglDiagnostic('metric-options-deferred-flush', { paneId: pane.id }) + return true +} + +export function hasDeferredPaneMetricOptions(pane: ManagedPane): boolean { + return deferredMetricOptions.has(pane.terminal) +} + +/** + * True when every value is already live and nothing is parked, so the caller can + * skip the apply. Why it matters: any settings write re-runs the appearance pass + * over every mounted pane, and arming a no-op deferral would make the next + * reveal refit for nothing. + */ +export function paneMetricOptionsAlreadySettled( + pane: ManagedPane, + options: PaneMetricOptions +): boolean { + if (deferredMetricOptions.has(pane.terminal)) { + return false + } + const target = pane.terminal.options + return ( + (options.fontSize === undefined || target.fontSize === options.fontSize) && + (options.fontFamily === undefined || target.fontFamily === options.fontFamily) && + (options.fontWeight === undefined || target.fontWeight === options.fontWeight) && + (options.fontWeightBold === undefined || target.fontWeightBold === options.fontWeightBold) && + (options.lineHeight === undefined || target.lineHeight === options.lineHeight) + ) +} + +/** + * Folds a directly-applied metric write into any pending deferral so a later + * flush cannot clobber it with the parked value. No-op when nothing is parked. + */ +export function overridePendingPaneMetricOptions( + pane: ManagedPane, + options: PaneMetricOptions +): void { + const pending = deferredMetricOptions.get(pane.terminal) + if (!pending) { + return + } + deferredMetricOptions.set(pane.terminal, { ...pending, ...options }) +} + +function writePaneMetricOptions(pane: ManagedPane, options: PaneMetricOptions): void { + const target = pane.terminal.options + if (options.fontSize !== undefined) { + target.fontSize = options.fontSize + } + if (options.fontFamily !== undefined) { + target.fontFamily = options.fontFamily + } + if (options.fontWeight !== undefined) { + target.fontWeight = options.fontWeight as typeof target.fontWeight + } + if (options.fontWeightBold !== undefined) { + target.fontWeightBold = options.fontWeightBold as typeof target.fontWeightBold + } + if (options.lineHeight !== undefined) { + target.lineHeight = options.lineHeight + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts index caf10deb62b..c3ac10f44b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-fit.test.ts @@ -9,14 +9,16 @@ const mocks = vi.hoisted(() => ({ readFitClientSize: vi.fn<(pane: ManagedPane) => { width: number; height: number } | null>(), requestStablePaneFit: vi.fn(), clearPaneFitContinuationRetry: vi.fn(), - resumePendingFitScrollRestoreAfterFit: vi.fn() + resumePendingFitScrollRestoreAfterFit: vi.fn(), + flushDeferredPaneMetricOptionsIfMeasurable: vi.fn(() => false) })) vi.mock('./pane-fit', () => ({ safeFit: mocks.safeFit, canMeasurePaneForFit: mocks.canMeasurePaneForFit, flushPendingSafeFitContinuations: mocks.flushPendingSafeFitContinuations, - readFitClientSize: mocks.readFitClientSize + readFitClientSize: mocks.readFitClientSize, + flushDeferredPaneMetricOptionsIfMeasurable: mocks.flushDeferredPaneMetricOptionsIfMeasurable })) vi.mock('./pane-fit-resize-observer', () => ({ requestStablePaneFit: mocks.requestStablePaneFit @@ -50,6 +52,44 @@ describe('fitRevealedPane routing', () => { beforeEach(() => { vi.clearAllMocks() mocks.canMeasurePaneForFit.mockReturnValue(true) + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(false) + }) + + it('repairs on a steady grid when a deferred metric flush lands on a pane the size checks would skip', () => { + // Without the flush branch the checks below both say "nothing to do" and the + // parked font change never reaches the grid. It must route through the stable + // path, not a raw fit: pixels are unchanged and the grid diverged, so a + // synchronous fit would reflow on the WebGL/DOM metric wobble and corrupt a + // diff-painting inline TUI. + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(true) + const pane = createPane({ + lastFitClientSize: { width: 800, height: 600 }, + currentSize: { width: 800, height: 600 }, + terminal: { cols: 80, rows: 24 }, + proposed: { cols: 80, rows: 24 } + }) + + fitRevealedPane(pane) + + expect(mocks.flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledWith(pane) + expect(mocks.requestStablePaneFit).toHaveBeenCalledWith(pane) + expect(mocks.safeFit).not.toHaveBeenCalled() + }) + + it('still flushes parked metric options when the pane also resized while hidden', () => { + mocks.flushDeferredPaneMetricOptionsIfMeasurable.mockReturnValue(true) + const pane = createPane({ + lastFitClientSize: { width: 800, height: 600 }, + currentSize: { width: 640, height: 480 }, + terminal: { cols: 80, rows: 24 }, + proposed: { cols: 64, rows: 20 } + }) + + fitRevealedPane(pane) + + expect(mocks.flushDeferredPaneMetricOptionsIfMeasurable).toHaveBeenCalledWith(pane) + // A real resize still takes the synchronous path, and the flush already landed. + expect(mocks.safeFit).toHaveBeenCalledWith(pane) }) it('fits synchronously when the fit element resized while hidden', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts b/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts index a397eee933d..f0125433764 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-fit.ts @@ -1,6 +1,7 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' import { canMeasurePaneForFit, + flushDeferredPaneMetricOptionsIfMeasurable, flushPendingSafeFitContinuations, readFitClientSize, safeFit @@ -61,11 +62,17 @@ function releaseMeasurableFitContinuations(pane: ManagedPane): void { // mismatch refits but a transient metric wobble does not reflow; // - grid already correct → leave it alone. export function fitRevealedPane(pane: ManagedPane): void { + // Why first: the checks below can both say "nothing to do" and return without + // fitting, stranding metric options parked while the pane was unmeasurable. + const flushed = flushDeferredPaneMetricOptionsIfMeasurable(pane) if (paneFitClientSizeChanged(pane)) { safeFit(pane) return } - if (!proposedGridMatchesTerminal(pane)) { + // Why the stable path for a flush: it leaves pixels unchanged but the grid + // diverged — the same shape as a snapshot resize, and a raw fit here would + // reflow on the transient WebGL/DOM metric wobble this function exists to avoid. + if (flushed || !proposedGridMatchesTerminal(pane)) { requestStablePaneFit(pane) return } diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 5cc07ef0e5b..151a4b02bbc 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,8 +65,9 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) - .__orcaE2eOrcaYamlUnreadable = false + ;( + globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } + ).__orcaE2eOrcaYamlUnreadable = false }) } From 2a2b517a8549e7b83fe28ba45f945896fa820656 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:41:50 -0700 Subject: [PATCH 83/88] Prevent empty agent-launch fallback on automation dispatch (#13068) The automation session owns the prompt-bearing agent tab. Passing undefined for createdWithAgent prevents spurious empty agent tabs from opening when dispatching automations. --- .../hooks/useAutomationDispatchEvents.test.ts | 13 ++++ .../src/hooks/useAutomationDispatchEvents.ts | 72 +++++++++---------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index d49778b3a05..46585b0091c 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -293,6 +293,19 @@ describe('useAutomationDispatchEvents setup launch', () => { expect(mockLaunchAgentBackgroundSession).toHaveBeenCalled() }) + it('does not stamp the created workspace with an empty agent-launch fallback', async () => { + await registerAndDispatch() + + expect(mockCreateWorktree.mock.calls[0][10]).toBeUndefined() + expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'claude', + prompt: 'run this', + worktreeId: 'wt-created' + }) + ) + }) + it('keeps launching the agent when background setup terminal launch fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) mockLaunchWorktreeBackgroundTerminals.mockRejectedValue(new Error('tab launch failed')) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index 09009d40011..f4ae2067688 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -220,43 +220,43 @@ export function useAutomationDispatchEvents(): void { const automationWorkspaceCreateRequestId = createBrowserUuid() const createResult = automation.workspaceMode === 'new_per_run' - ? await useAppStore - .getState() - .createWorktree( - runRepoId, - buildAutomationWorkspaceName(run.title, run.scheduledFor), - automation.baseBranch ?? undefined, - automation.setupDecision ?? 'skip', - undefined, - 'unknown', - run.title, - undefined, - undefined, - undefined, - automation.agentId, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { - automationProvenanceRequest: { - automationId: automation.id, - automationRunId: run.id, - dispatchToken, - createRequestId: automationWorkspaceCreateRequestId - } + ? await useAppStore.getState().createWorktree( + runRepoId, + buildAutomationWorkspaceName(run.title, run.scheduledFor), + automation.baseBranch ?? undefined, + automation.setupDecision ?? 'skip', + undefined, + 'unknown', + run.title, + undefined, + undefined, + undefined, + // Why: the automation session below owns the prompt-bearing + // agent tab; createdWithAgent would reopen an empty fallback. + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + automationProvenanceRequest: { + automationId: automation.id, + automationRunId: run.id, + dispatchToken, + createRequestId: automationWorkspaceCreateRequestId } - ) + } + ) : null const worktree = createResult ? createResult.worktree From 58531e4caa7a8ed48c1dede4e00e87369ac8a95f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:42:40 -0700 Subject: [PATCH 84/88] Fix grok stale pane width (#13060) * fix(terminal): restore canonical terminal rows from unified sessions During session hydration, non-canonical terminal rows (closed/stale tabs) were being restored alongside canonical ones. Filter to only canonical terminal rows from unified sessions, treating legacy rows as terminal backing data only. * fix(terminal): preserve independent legacy PTY rows --- ...terminals-hydration-canonical-rows.test.ts | 130 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 30 +++- 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts new file mode 100644 index 00000000000..87563472922 --- /dev/null +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-rows.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { createTestStore, makeLayout, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +const apiProxy = (): unknown => + new Proxy(() => undefined, { + get: (_target, prop) => (prop === 'then' ? undefined : apiProxy()), + apply: () => Promise.resolve(null) + }) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: apiProxy() } + +describe('hydrateWorkspaceSession canonical terminal rows', () => { + it('drops only legacy rows that duplicate canonical PTY ownership', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + const sharedPtyId = 'daemon-session-1' + const recoveryPtyId = 'daemon-session-2' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: 'stale-tab', + activeWorktreeIdsOnShutdown: [worktreeId], + activeTabIdByWorktree: { [worktreeId]: 'stale-tab' }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'canonical-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'stale-tab', worktreeId, ptyId: sharedPtyId }), + makeTab({ id: 'recovery-tab', worktreeId, ptyId: recoveryPtyId }) + ] + }, + terminalLayoutsByTabId: { + 'canonical-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } + }, + 'stale-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'stale-leaf': sharedPtyId } + }, + 'recovery-tab': { + ...makeLayout(), + ptyIdsByLeafId: { 'recovery-leaf': recoveryPtyId } + } + }, + remoteSessionIdsByTabId: { + 'canonical-tab': sharedPtyId, + 'stale-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }, + unifiedTabs: { + [worktreeId]: [ + { + id: 'canonical-unified-tab', + entityId: 'canonical-tab', + groupId: 'group-1', + worktreeId, + contentType: 'terminal', + label: 'Grok', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + tabGroups: { + [worktreeId]: [ + { + id: 'group-1', + worktreeId, + activeTabId: 'canonical-unified-tab', + tabOrder: ['canonical-unified-tab'] + } + ] + } + } + + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + const reconciliation = store.getState().reconcileWorktreeTabModel(worktreeId) + const state = store.getState() + const persisted = buildWorkspaceSessionPayload(state) + + expect(reconciliation.renderableTabCount).toBe(2) + expect(state.unifiedTabsByWorktree[worktreeId]?.map((tab) => tab.entityId)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(persisted.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(persisted.terminalLayoutsByTabId['stale-tab']).toBeUndefined() + expect(state.pendingReconnectTabByWorktree[worktreeId]).toEqual([ + 'canonical-tab', + 'recovery-tab' + ]) + expect(state.pendingReconnectPtyIdByTabId).toEqual({ + 'canonical-tab': sharedPtyId, + 'recovery-tab': recoveryPtyId + }) + expect(state.activeTabId).toBeNull() + expect(state.activeTabIdByWorktree).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 7167e1cfc4d..87729c71ddb 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -492,6 +492,17 @@ function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] } +function collectPersistedTerminalPtyIds( + session: WorkspaceSessionState, + tab: TerminalTab +): string[] { + return uniquePtyIds([ + tab.ptyId, + session.remoteSessionIdsByTabId?.[tab.id], + ...Object.values(session.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ]) +} + function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined @@ -3943,6 +3954,16 @@ export const createTerminalSlice: StateCreator Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map(([worktreeId, tabs]) => { + const canonicalTerminalIds = new Set( + (session.unifiedTabs?.[worktreeId] ?? []).flatMap((tab) => + tab.contentType === 'terminal' ? [tab.entityId] : [] + ) + ) + const canonicalPtyIds = new Set( + tabs + .filter((tab) => canonicalTerminalIds.has(tab.id)) + .flatMap((tab) => collectPersistedTerminalPtyIds(session, tab)) + ) const quickCommandLabelByTerminalId = new Map( (session.unifiedTabs?.[worktreeId] ?? []) .filter((tab) => tab.contentType === 'terminal' && tab.quickCommandLabel?.trim()) @@ -3957,8 +3978,15 @@ export const createTerminalSlice: StateCreator worktreeId, [...tabs] .filter((tab) => { + // Why: canonical mounts win PTY ownership over stale legacy duplicates. // Why: old web-client mirrors could persist host surface ids with "::"; makePaneKey reserves ":" as its separator. - return isValidTerminalTabId(tab.id) + return ( + (canonicalTerminalIds.has(tab.id) || + !collectPersistedTerminalPtyIds(session, tab).some((ptyId) => + canonicalPtyIds.has(ptyId) + )) && + isValidTerminalTabId(tab.id) + ) }) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .map((tab, index) => { From a77002c42b6bf74d6a87bbebfefde21a9e2a6c37 Mon Sep 17 00:00:00 2001 From: 5hseok <110245258+5hseok@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:47:26 +0900 Subject: [PATCH 85/88] feat(ai-vault): delete a provider session from the AI Vault list (#10249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault): validate session-delete targets for single-file providers Add the pure judgement layer for deleting an Agent Session History entry. `validateAiVaultSessionDeleteTarget` decides whether a session may be removed: the agent must be one of the nine providers where a single file is the whole session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp), the host must be local, and the renderer-supplied path must resolve inside that agent's own session roots and match its discovery predicate. To keep the delete roots from drifting from the scanner's own roots, the WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw root derivation + session predicate become shared helpers that discoverOpenClawFiles itself consumes. The result is path-only and never touches the filesystem; a returned `allowed: true` still requires an lstat/realpath re-check in the executor (S-2) before removal, documented as a caller contract on the result type. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): move a validated session transcript to the trash Add the filesystem executor behind session deletion. It calls the S-1 path validator, then performs the fs-side guards that validator documented it could not: lstat().isFile() rejects a directory or symlink, and realpath is re-fed through the validator so a regular file reached through a symlinked parent that escapes the agent's roots is rejected too. Only then is the file moved to the OS trash via shell.trashItem, with ENOENT treated as success so a delete racing an external removal stays idempotent. WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is returned as a failure result rather than thrown, since IPC payloads are untyped at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): delete-session IPC handler, preload bridge, cache invalidation Wire the S-2 delete executor to an IPC endpoint and expose it on the preload bridge. The renderer calls aiVault:deleteSession with { agent, filePath, executionHostId }; the handler fetches WSL homes, delegates to the executor (which re-validates and trashes), and on a real delete invalidates the caches that could otherwise keep serving the deleted session. Cache invalidation is generation-guarded: a scan already in flight when the delete lands carries an older generation and must not write its pre-delete result back into the cache. Without this, an in-flight scan resolving just after the delete would resurrect the deleted session for the 15s TTL — and force-refreshing the panel only masks it for the desktop, not for the paired mobile client or runtime RPC that share the same cache module. Both the shared local-scope cache and the desktop multi-host cache carry the guard, with regression tests for the in-flight race. The delete result type moves to shared/ai-vault-types.ts so the renderer can import the same contract the executor returns. To keep ai-vault.ts within the max-lines budget after adding the delete wiring, two cohesive pieces are extracted to their own files: the delete orchestration (ai-vault-delete.ts) and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the only handler with no dependency on this module's private cache state, so it is the one piece that moves verbatim without threading state through a seam. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): renderer judgement for whether Delete is offered Add the renderer counterpart to the main-side delete validator: given a session, decide whether the row menu shows Delete enabled, or disabled with a reason a tooltip can render. It reuses the shared deletable-agent set and unsupported-reason map so the two sides can never disagree about which agents are deletable, and reuses the existing local-host / synthetic-path renderer helpers. This is intentionally not a security boundary — it validates neither the path root nor the file predicate. Those are the main process's untrusted-input defense; the renderer only picks the affordance, and the main side re-checks on delete regardless. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * docs(ai-vault): correct deletability parity claim; test multi-reason agent The renderer deletability check runs host -> synthetic -> agent, while the main validator runs agent -> host -> synthetic. The two layers agree only on deletable-or-not (renderer-false is a subset of main-false), not on the reason code a doubly-failing session carries. Document that explicitly instead of implying the orders match, and add the antigravity case (two reason codes) so the agentReasonCodes array shape is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * feat(ai-vault): add Delete to the session row menu with a confirmation dialog Wire the delete affordance into AI Vault. Both the dropdown and the context menu gain a destructive Delete item; a session that can't be completely deleted (remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed agent) shows the item disabled with a reason surfaced both as a tooltip and as an aria-label so keyboard and screen-reader users learn why. Confirming opens a dialog that names the session and states it will no longer be resumable from the provider's own CLI, then calls the delete IPC and force-refreshes the list for immediate feedback (the main side has already invalidated its caches). The confirmation copy says the session "will be deleted" rather than "moved to the trash": on Windows a WSL session is deleted with rm inside the distro (no Recycle Bin), so promising recoverability would be a lie on that platform. Deletability is computed once per row and shared by both menus so they can never disagree. New pure logic — the reason-to-tooltip mapping (including the multi-reason join) and the delete action hook's deleted/rejected/failed branches — is covered by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i * fix(ai-vault): state that Delete is unavailable without naming the cause The disabled Delete item explained a provider's storage layout to the user ("Claude sessions can't be deleted here: stores sessions as a folder, not a single file"). That is Orca's problem, not the reader's — the tooltip now says which sessions are affected and stops there. The non-local-host string stays as it was: it states scope, not a cause, and tells the user what would work. The reason-code plumbing existed only to compose that tooltip, so AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the renderer result's agentReasonCodes field go with it. Why each agent is excluded moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking up the deletable set will find it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * feat(ai-vault): delete claude, rovo, and grok sessions by their directory These three were excluded only because the delete unit was one file. Their sessions are directories — claude keeps Task subagent transcripts in a sibling `/subagents/`, rovo and grok keep everything under `/` — and nothing in them is shared with another session, so a directory-aware delete is still a complete delete. Supported goes from 9 agents to 12; the four that remain (antigravity, kimi, codex, opencode) are blocked by a registry or a SQLite row, which no delete unit fixes. Validation now returns an ordered removal plan instead of a single path. Each removal carries the kind it must be on disk and the roots its realpath must stay inside, so the executor's guard is the same shape for a file and for a directory. Companions come first and the transcript last: the transcript is what puts the row on screen, so a part-way failure leaves the row to retry from rather than dropping it and stranding the rest on disk. Claude's `session-env//` goes with the transcript — it holds that session's generated shell exports and nothing else. Its sibling `file-history//` deliberately does not: it is the rewind buffer holding earlier versions of the user's own files, and retiring a session is no reason to take away the only copy that can restore them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): remove a claude session's own directory, not just its subagents Deleting a claude session trashed `/subagents/` and left `/` behind as an empty directory — one per deleted session, accumulating under every project. The directory is named after the transcript, so it belongs to that session as a whole; take it rather than the one subdirectory inside it. Still derived from the scanner's own subagents path, so the two cannot drift. Reaching the parent means a degenerate stem now matters: `..jsonl` passes the extension check and its stem is `.`, which would resolve the session directory to the project directory holding every session. Reject it instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): keep a session row collapsed when a menu action is chosen Radix portals the row's dropdown and context menus out of its DOM, but React still bubbles their clicks back through the component tree, so every menu selection also hit the row's own click handler and expanded it. The trigger button already stopped propagation, which is why opening the menu looked fine and only choosing an item misbehaved. It shows worst on Delete: the row expands behind the confirm dialog, so cancelling leaves the list rearranged under a dialog the user just backed out of. Toggle details only for clicks that land in the row's own subtree — that covers the context menu and any future portalled surface, not just this one. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7 * fix(ai-vault): harden the delete-confirmation flow against IPC rejection and mid-delete dismissal Two robustness gaps flagged in review: - handleConfirmDelete only branched on result.outcome. The main handler resolves with a 'failed'/'rejected' outcome rather than throwing, but the IPC invoke itself can still reject on a transport/serialization error, and the caller fires it with `void`. That reject would surface as an unhandled rejection with no toast. Catch it and show the same generic failure toast. - handleDialogOpenChange cleared sessionPendingDelete on every open=false. The Cancel button is disabled mid-delete, but Radix still fires its Escape/outside-click/X close, which could dismiss an in-flight delete out from under itself. Ignore close requests while deletingSession is true. Both covered by regression tests (verified failing without the fix). * fix(ai-vault): route WSL UNC directory removals through the WSL rm branch Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's session dir) gated the WSL branch on kind === 'file', so on Windows a session under a WSL distro home fell through to shell.trashItem — which can't trash a WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when the 9P filesystem's unreliable lstat false-reports ENOENT and the executor treats that as success. Single-file deletes predate the directory kinds, so the file-only gate was correct until directory removals were added. tryDeleteWslUncPath already supports recursive removal; pass recursive for directory removals so they take the same WSL rm path as files instead of shell.trashItem. Covered by two regression tests (file: non-recursive, directory: recursive), verified failing without the fix. Also drops the internal ledger-ID references (D-*, S-*) from comments in these two files; they pointed at a private design doc a reader can't see. * docs(ai-vault): drop internal design-ledger IDs from shipped comments Comments across the session-delete feature cited decision/slice IDs (D-1..D-7, S-1..S-5) from a private design document. Those references are meaningless to anyone reading the code without that doc, so remove the IDs while keeping the reasoning each comment carried. No behavior change. * test(ai-vault): e2e-cover the real on-disk session delete The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC path actually removes files. This spec seeds sessions into the E2E harness's isolated HOME and deletes them through window.api.aiVault.deleteSession: - a single-file session (gemini): the transcript is gone from disk and drops out of the list. - a directory-shaped session (claude): the transcript, the / session directory (subagents included, no empty shell left), and the session-env companion are all gone, while the file-history rewind buffer is preserved. Verified failing when the executor's removal is stubbed out. Runs on Linux CI. * fix(ai-vault): address review findings on the session-delete flow Three points raised in review: - Disable Delete for a still-running session. resolveAiVaultSessionDeletability now gates on liveState (working/blocked/waiting) last — an otherwise-deletable session that is mid-run shows "wait for it to finish" instead of an enabled Delete, so trashing a live agent's transcript can't drop writes it is still appending. Unsupported/remote sessions keep their permanent reason. - Realpath the roots, not just the target, in the executor's escape check. The roots were only resolve()'d (text), so a session under a symlinked root (~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling back to its text form when it can't be resolved) before the membership check. - Invalidate the parse cache with the raw filePath, not resolve(filePath). The cache is keyed by the exact path the scanner discovered, so resolve() could normalise it away from the stored key and miss. Drops the now-unused import. Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion domain module they belong to, and updates importers. Regression tests added for the live gate, the symlinked-root accept, and the reason string; verified failing without each fix. * fix(ai-vault): type the deleteSession preload bridge as its real result The bridge declared Promise while AiVaultApi.deleteSession promises AiVaultDeleteSessionResult, so the preload object leaned on the api-types declaration to stay honest instead of being checked against it. Co-authored-by: Orca * refactor(ai-vault): tighten the session-delete code to house style Comments across the delete flow explained HOW alongside WHY and ran to a dozen lines; they now carry only the non-obvious reasoning. The excluded-agent rationale, the caller contract on the validator, and the file-history carve-out are kept — those are knowledge, not narration. Also removes three duplications the feature introduced: - AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult whose comment pointed at a module the type no longer lives in. - The synthetic-path predicate existed twice under near-identical names; the renderer now re-exports the shared one it already had a sibling import of. - The delete-failure toast was written out verbatim in both the rejected and the thrown branch. Co-authored-by: Orca * refactor(ai-vault): use a design-system dialog width and a stable row selector The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in the right sidebar uses a scale token, and md (448px) covers the role. The row-expand test selected the row by [draggable="true"], which stopped naming the row when draggable moved to the title element upstream. It still passed by bubbling, so the comment was the only thing wrong — now it selects the title deliberately and says why the query is first-match (Radix's asChild trigger repeats the subtree, so screen.get* sees duplicates). Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a hand-written { outcome: string }, now that the preload bridge returns it. Co-authored-by: Orca * refactor(ai-vault): consolidate agent sources and use system dialog Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- src/main/ai-vault/cached-session-list.test.ts | 74 +++ src/main/ai-vault/cached-session-list.ts | 27 +- .../ai-vault/session-delete-target.test.ts | 437 ++++++++++++++++++ src/main/ai-vault/session-delete-target.ts | 145 ++++++ src/main/ai-vault/session-delete.test.ts | 293 ++++++++++++ src/main/ai-vault/session-delete.ts | 104 +++++ .../ai-vault/session-scanner-agent-sources.ts | 254 ++++++++++ .../ai-vault/session-scanner-discovery.ts | 26 +- .../session-scanner-droid-kimi-sources.ts | 61 --- .../ai-vault/session-scanner-parse-cache.ts | 6 + .../session-scanner-source-discovery.ts | 311 ++----------- src/main/ipc/ai-vault-delete.ts | 57 +++ src/main/ipc/ai-vault-host-leg-cache.ts | 22 +- src/main/ipc/ai-vault-subagent-list.ts | 51 ++ src/main/ipc/ai-vault.test.ts | 148 +++++- src/main/ipc/ai-vault.ts | 60 +-- src/preload/api-types.ts | 6 + src/preload/index.ts | 6 + .../components/right-sidebar/AiVaultPanel.tsx | 4 + .../AiVaultSessionActionMenuItems.tsx | 44 +- .../right-sidebar/AiVaultSessionRow.test.tsx | 95 ++++ .../right-sidebar/AiVaultSessionRow.tsx | 21 +- .../AiVaultSessionVirtualList.tsx | 10 +- .../SessionRowTrailingActions.test.tsx | 2 + .../SessionRowTrailingActions.tsx | 9 +- .../ai-vault-session-deletability.test.ts | 100 ++++ .../ai-vault-session-deletability.ts | 60 +++ .../ai-vault-session-delete-action.ts | 68 +++ .../ai-vault-session-path-actions.ts | 15 +- src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- src/renderer/src/web/web-preload-api.ts | 10 + src/shared/ai-vault-session-deletion.ts | 105 +++++ tests/e2e/ai-vault-session-delete.spec.ts | 127 +++++ 37 files changed, 2408 insertions(+), 430 deletions(-) create mode 100644 src/main/ai-vault/cached-session-list.test.ts create mode 100644 src/main/ai-vault/session-delete-target.test.ts create mode 100644 src/main/ai-vault/session-delete-target.ts create mode 100644 src/main/ai-vault/session-delete.test.ts create mode 100644 src/main/ai-vault/session-delete.ts create mode 100644 src/main/ai-vault/session-scanner-agent-sources.ts delete mode 100644 src/main/ai-vault/session-scanner-droid-kimi-sources.ts create mode 100644 src/main/ipc/ai-vault-delete.ts create mode 100644 src/main/ipc/ai-vault-subagent-list.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts create mode 100644 src/shared/ai-vault-session-deletion.ts create mode 100644 tests/e2e/ai-vault-session-delete.spec.ts diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts new file mode 100644 index 00000000000..49577d3e519 --- /dev/null +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' + +const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() })) + +vi.mock('./session-scanner', () => ({ scanAiVaultSessions })) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(), + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +import { + invalidateAiVaultSessionListCache, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from './cached-session-list' + +function scanResult(scannedAt: string): AiVaultListResult { + return { sessions: [], issues: [], scannedAt } +} + +// A scan whose resolution the test controls, so an invalidation can be injected +// mid-flight. +function deferredScan(): { resolve: (value: AiVaultListResult) => void } { + let resolveFn: (value: AiVaultListResult) => void = () => {} + scanAiVaultSessions.mockReturnValueOnce( + new Promise((resolve) => { + resolveFn = resolve + }) + ) + return { resolve: resolveFn } +} + +describe('invalidateAiVaultSessionListCache generation guard', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessions.mockReset() + }) + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) + + it('does not let a scan that started before an invalidation repopulate the cache', async () => { + // Scan A starts and is still in flight. + const scanA = deferredScan() + const inFlight = listAiVaultSessions() + + // A delete invalidates the cache while A is running. + invalidateAiVaultSessionListCache() + + // A now resolves with a pre-delete result. + scanA.resolve(scanResult('scan-A')) + await inFlight + + // A non-force list must re-scan (cache empty) rather than serve A's stale + // result — proof A's late .then() did not repopulate the cache. + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-B')) + const next = await listAiVaultSessions() + + expect(next.scannedAt).toBe('scan-B') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + it('caches normally when no invalidation interrupts the scan', async () => { + scanAiVaultSessions.mockResolvedValueOnce(scanResult('scan-A')) + await listAiVaultSessions() + + // Second non-force call is a cache hit — no second scan. + const cached = await listAiVaultSessions() + + expect(cached.scannedAt).toBe('scan-A') + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 875ab37310c..6a92bedb844 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -34,6 +34,11 @@ type CachedAiVaultList = { let cachedList: CachedAiVaultList | null = null let scanCoordinator = new AiVaultScanCoordinator() let sources: AiVaultSessionSources = {} +// Bumped on every invalidation. A scan that started before an invalidation +// carries the old generation and must not write its (now stale) result back +// into the cache — otherwise a delete's invalidation is silently undone by an +// in-flight scan that resolves just after it. +let cacheGeneration = 0 export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { sources = next @@ -58,6 +63,10 @@ export async function listAiVaultSessions( ) { return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } + // Captured here, not inside start(): the coordinator defers start() by a + // microtask, so an invalidation landing in that gap would otherwise be read + // as having happened before this scan and leave the stale result cacheable. + const startGeneration = cacheGeneration return scanCoordinator.run({ key: scanKey, force: args?.force, @@ -78,7 +87,10 @@ export async function listAiVaultSessions( // runtime id get the result restamped at the RPC edge, never rescanned. executionHostId: LOCAL_EXECUTION_HOST_ID }) - if (!scanSignal.aborted) { + // A delete (or other invalidation) landed while this scan was running: + // its result predates the delete, so caching it would resurrect the + // deleted session for the TTL. Return it to this caller but don't cache. + if (!scanSignal.aborted && startGeneration === cacheGeneration) { const current = cachedList if ( args?.force === true || @@ -111,9 +123,20 @@ export async function getAiVaultWslHomeDirs(): Promise { return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) } +// Drops the scan-result cache after a session is deleted so a non-force +// list call within the TTL can't still serve the trashed session. Bumping the +// generation also disarms any scan already in flight, so a scan that started +// before this call cannot write its pre-delete result back into the cache. +// Scan discovery itself walks disk first (session-scanner.ts), so a deleted +// file is never rediscovered — this only guards the cached RESULT. +export function invalidateAiVaultSessionListCache(): void { + cacheGeneration++ + cachedList = null +} + // Why: tests reset module-level cache/source state between cases. export function resetAiVaultSessionListCacheForTests(): void { - cachedList = null + invalidateAiVaultSessionListCache() scanCoordinator = new AiVaultScanCoordinator() sources = {} } diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts new file mode 100644 index 00000000000..39c95f4f5f6 --- /dev/null +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -0,0 +1,437 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { validateAiVaultSessionDeleteTarget } from './session-delete-target' + +// All roots are supplied via rootOptions so these tests never touch the real +// home directory or filesystem — validation is pure string-path judgement. +const HOME = join('/tmp', 'orca-ai-vault-delete-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CURSOR_ROOT = join(HOME, '.cursor', 'projects') +const HERMES_ROOT = join(HOME, '.hermes', 'sessions') +const OPENCLAW_ROOT = join(HOME, '.openclaw') +const COPILOT_ROOT = join(HOME, '.copilot', 'session-state') +const DEVIN_ROOT = join(HOME, '.local', 'share', 'devin', 'cli', 'transcripts') +const PI_ROOT = join(HOME, '.pi', 'agent', 'sessions') +const OMP_ROOT = join(HOME, '.omp', 'agent', 'sessions') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const CLAUDE_SESSION_ENV_ROOT = join(HOME, '.claude', 'session-env') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') +const GROK_ROOT = join(HOME, '.grok', 'sessions') + +describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a supported agent whose file resolves inside its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ + allowed: true, + agent: 'gemini', + resolvedPath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + removals: [ + { + path: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + kind: 'file', + roots: [GEMINI_ROOT] + } + ] + }) + }) + + it('allows gemini .jsonl files too, since discovery accepts both extensions', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a path that escapes its root via ..', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, '..', '..', '..', 'etc', 'passwd.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a path entirely outside the known roots', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(HOME, 'Documents', 'notes.json'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'path-outside-known-roots' }) + }) + + it('rejects a file whose extension the agent never discovers', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.txt'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('rejects a cursor path missing the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'cursor', reason: 'undiscoverable-path' }) + }) + + it('allows a cursor path that does carry the agent-transcripts segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cursor', + filePath: join(CURSOR_ROOT, 'proj', 'agent-transcripts', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { cursorProjectsDir: CURSOR_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects a hermes file whose basename is missing the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'not-a-session.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'hermes', reason: 'undiscoverable-path' }) + }) + + it('allows a hermes file whose basename carries the session_ prefix', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'hermes', + filePath: join(HERMES_ROOT, 'session_abc123.json'), + executionHostId: 'local', + rootOptions: { hermesSessionsDir: HERMES_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an openclaw path under /agents/.../sessions/...', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('rejects an openclaw path missing the sessions segment', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(OPENCLAW_ROOT, 'agents', 'agent-1', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: OPENCLAW_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'openclaw', reason: 'undiscoverable-path' }) + }) + + it('rejects an agent whose own registry would keep a dangling entry (codex)', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'codex', + filePath: join(HOME, '.codex', 'sessions', 'rollout-1.jsonl'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'codex', reason: 'unsupported-agent' }) + }) + + it('rejects a non-local execution host', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.json'), + executionHostId: 'ssh:some-host', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'non-local-host' }) + }) + + // A real OpenCode SQLite-row session (the `#` identity) + // reaches the validator as agent 'opencode', which is stopped at the agent + // gate — so this is how such a session actually enters judgement. + it('rejects a real opencode session as an unsupported agent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'opencode', + filePath: join(HOME, '.local', 'share', 'opencode', 'db.sqlite#session-1'), + executionHostId: 'local' + }) + expect(result).toEqual({ allowed: false, agent: 'opencode', reason: 'unsupported-agent' }) + }) + + // Defense-in-depth: even for a deletable agent, any '#'-bearing path is + // treated as a synthetic SQLite identity rather than a real file to delete. + it('rejects a deletable-agent path bearing a synthetic # marker', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'db.sqlite#session-1'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'synthetic-path' }) + }) + + it('rejects a blank filePath', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: ' ', + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result).toEqual({ allowed: false, agent: 'gemini', reason: 'invalid-path' }) + }) + + it('allows a droid path under either of its .factory roots', () => { + const sessionsRoot = join(HOME, '.factory', 'sessions') + const projectsRoot = join(HOME, '.factory', 'projects') + const underSessions = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(sessionsRoot, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underSessions.allowed).toBe(true) + const underProjects = validateAiVaultSessionDeleteTarget({ + agent: 'droid', + filePath: join(projectsRoot, 'proj-a', 'session-2.jsonl'), + executionHostId: 'local', + rootOptions: { droidSessionsDir: sessionsRoot, droidProjectsDir: projectsRoot } + }) + expect(underProjects.allowed).toBe(true) + }) + + it('allows a copilot .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'copilot', + filePath: join(COPILOT_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { copilotSessionsDir: COPILOT_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a devin .json file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'devin', + filePath: join(DEVIN_ROOT, 'session-1.json'), + executionHostId: 'local', + rootOptions: { devinTranscriptsDir: DEVIN_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a pi .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'pi', + filePath: join(PI_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { piSessionsDir: PI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows an omp .jsonl file under its known root', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'omp', + filePath: join(OMP_ROOT, 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { ompSessionsDir: OMP_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // Discovery matches extensions case-insensitively (walkSessionFiles folds via + // toLowerCase); the validator must accept the same uppercase-extension file. + it('allows an uppercase extension since discovery folds extension case', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(GEMINI_ROOT, 'project-a', 'session-1.JSON'), + executionHostId: 'local', + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + it('allows a gemini file under a WSL-expanded root', () => { + const wslHome = join('/tmp', 'orca-ai-vault-delete-fixture-wsl-home') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'gemini', + filePath: join(wslHome, '.gemini', 'tmp', 'project-a', 'session-1.json'), + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + }) + expect(result.allowed).toBe(true) + }) + + // A stateDir that already ends in `agents` must not be doubled to + // `agents/agents`; openClawAgentsRootDir uses it as-is. + it('allows an openclaw path whose stateDir already ends in agents', () => { + const agentsRoot = join(OPENCLAW_ROOT, 'agents') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'openclaw', + filePath: join(agentsRoot, 'agent-1', 'sessions', 'session-1.jsonl'), + executionHostId: 'local', + rootOptions: { openclawStateDir: agentsRoot } + }) + expect(result.allowed).toBe(true) + }) +}) + +// The directory-shaped agents. Their delete unit is a path set derived from the +// one file discovery surfaced, not that file alone. +describe('directory-shaped agents', () => { + it('plans claude companions before the transcript, so a failure keeps the row', () => { + const filePath = join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'claude', + resolvedPath: filePath, + removals: [ + { + path: join(CLAUDE_ROOT, '-proj', 'sess-1'), + kind: 'directory', + roots: [CLAUDE_ROOT] + }, + { + path: join(CLAUDE_SESSION_ENV_ROOT, 'sess-1'), + kind: 'directory', + roots: [CLAUDE_SESSION_ENV_ROOT] + }, + { path: filePath, kind: 'file', roots: [CLAUDE_ROOT] } + ] + }) + }) + + it("never plans file-history, the rewind buffer for the user's own files", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed).toBe(true) + expect( + result.allowed && result.removals.some((removal) => removal.path.includes('file-history')) + ).toBe(false) + }) + + it("pairs a WSL-home claude session with that distro's session-env, not the local one", () => { + const wslHome = join('/tmp', 'orca-wsl-home') + const filePath = join(wslHome, '.claude', 'projects', '-proj', 'sess-2.jsonl') + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath, + executionHostId: 'local', + wslHomeDirs: [wslHome], + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result.allowed && result.removals[1]).toEqual({ + path: join(wslHome, '.claude', 'session-env', 'sess-2'), + kind: 'directory', + roots: [join(wslHome, '.claude', 'session-env')] + }) + }) + + it('refuses a transcript whose stem would resolve to the project dir itself', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', '..jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'no-session-directory' + }) + }) + + it('rejects a Task subagent transcript, which is only ever removed with its parent', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'claude', + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1', 'subagents', 'agent-abc.jsonl'), + executionHostId: 'local', + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + }) + + expect(result).toEqual({ + allowed: false, + agent: 'claude', + reason: 'undiscoverable-path' + }) + }) + + it("removes rovo's session directory, not just the metadata.json inside it", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ + allowed: true, + agent: 'rovo', + resolvedPath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + removals: [{ path: join(ROVO_ROOT, 'sess-1'), kind: 'directory', roots: [ROVO_ROOT] }] + }) + }) + + it("removes grok's session directory under its encoded-cwd group", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, '-Users-me-proj', 'sess-1', 'summary.json'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result.allowed && result.removals).toEqual([ + { path: join(GROK_ROOT, '-Users-me-proj', 'sess-1'), kind: 'directory', roots: [GROK_ROOT] } + ]) + }) + + it("rejects grok's chat_history.jsonl — only summary.json names a session", () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'grok', + filePath: join(GROK_ROOT, 'group', 'sess-1', 'chat_history.jsonl'), + executionHostId: 'local', + rootOptions: { grokSessionsDir: GROK_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'grok', reason: 'undiscoverable-path' }) + }) + + it('refuses a metadata.json sitting in the sessions root, which would trash every session', () => { + const result = validateAiVaultSessionDeleteTarget({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ allowed: false, agent: 'rovo', reason: 'no-session-directory' }) + }) +}) diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts new file mode 100644 index 00000000000..11ef38039dc --- /dev/null +++ b/src/main/ai-vault/session-delete-target.ts @@ -0,0 +1,145 @@ +import { basename, dirname, extname, join, resolve } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { + isAiVaultDeletableAgent, + isAiVaultSyntheticSessionPath, + type AiVaultDeletableAgent, + type AiVaultSessionDeleteRejectionCode, + type AiVaultSessionDeleteRemoval, + type AiVaultSessionDeleteValidationResult +} from '../../shared/ai-vault-session-deletion' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { AI_VAULT_AGENT_SOURCES, isDiscoverableSessionFile } from './session-scanner-agent-sources' +import { subagentTranscriptsDirFor } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' + +// Agents whose session IS the directory holding the scanned file: everything +// beside it belongs to the same session (rovo's session_context.json, grok's +// chat_history.jsonl), so the directory is the only complete delete unit. +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) + +export type ValidateAiVaultSessionDeleteTargetArgs = { + agent: AiVaultAgent + filePath: string + executionHostId: ExecutionHostId | null | undefined + // Passed in rather than fetched (getAiVaultWslHomeDirs) so this stays + // synchronous and pure. + wslHomeDirs?: readonly string[] + // Per-agent root overrides so tests never depend on the real home directory. + rootOptions?: AiVaultScanOptions +} + +// Pure path-only judgement: never touches the filesystem, and never throws — +// a malformed or hostile filePath rejects like any other invalid input. +// `allowed: true` alone is NOT enough to delete; see the caller contract on +// AiVaultSessionDeleteAllowedResult. +export function validateAiVaultSessionDeleteTarget( + args: ValidateAiVaultSessionDeleteTargetArgs +): AiVaultSessionDeleteValidationResult { + const { agent } = args + const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '' + if (!filePath) { + return rejected(agent, 'invalid-path') + } + if (!isAiVaultDeletableAgent(agent)) { + return rejected(agent, 'unsupported-agent') + } + // Electron shell/fs APIs only act on this computer; an ssh/runtime session's + // path exists on the remote host. Same limit as Open/Reveal Log. + if (normalizeExecutionHostId(args.executionHostId) !== LOCAL_EXECUTION_HOST_ID) { + return rejected(agent, 'non-local-host') + } + if (isAiVaultSyntheticSessionPath(filePath)) { + return rejected(agent, 'synthetic-path') + } + + // resolve() collapses `..` first: isPathInsideOrEqual compares textually and + // would otherwise pass `/../../etc/x.jsonl`. + const resolvedPath = resolve(filePath) + const source = AI_VAULT_AGENT_SOURCES[agent] + const roots = source + .rootDirs(args.rootOptions ?? {}, args.wslHomeDirs ?? []) + // Why: OMP_CODING_AGENT_DIR='/' normalizes to '', which resolve()s to the + // process cwd — an empty root would silently allowlist it. + .filter((rootDir) => rootDir.trim().length > 0) + .map((rootDir) => resolve(rootDir)) + // Keep the root that actually contains this path: companion roots are derived + // from it, so a WSL-home session can't pair with the local host's companions. + const matchedRoot = roots.find((root) => isPathInsideOrEqual(root, resolvedPath)) + if (!matchedRoot) { + return rejected(agent, 'path-outside-known-roots') + } + // The scanner's own accept rule, so a path it would never have surfaced as a + // session row can't be accepted as a delete target either. + if (!isDiscoverableSessionFile(source, matchedRoot, resolvedPath)) { + return rejected(agent, 'undiscoverable-path') + } + + const removals = sessionDeleteRemovals({ agent, resolvedPath, matchedRoot, roots }) + if (!removals) { + return rejected(agent, 'no-session-directory') + } + + return { allowed: true, agent, resolvedPath, removals } +} + +// The ordered path set that removing this session means: companions first, the +// scanned path last. Null when the path names no session directory of its own, +// which would otherwise reach the root holding every session. +// +// `session-env//` is a companion — it holds that session's generated +// shell exports and nothing else. Its sibling `file-history//` is +// deliberately NOT: that is the rewind buffer holding earlier versions of the +// user's own files, and retiring a session is no reason to destroy the only +// copy that can restore them. +function sessionDeleteRemovals(args: { + agent: AiVaultDeletableAgent + resolvedPath: string + matchedRoot: string + roots: readonly string[] +}): readonly AiVaultSessionDeleteRemoval[] | null { + const { agent, resolvedPath, matchedRoot, roots } = args + + if (AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS.has(agent)) { + const sessionDir = dirname(resolvedPath) + if (sessionDir === matchedRoot || !isPathInsideOrEqual(matchedRoot, sessionDir)) { + return null + } + return [{ path: sessionDir, kind: 'directory', roots }] + } + + if (agent === 'claude') { + const sessionId = basename(resolvedPath, extname(resolvedPath)) + // A degenerate stem ('.' from `..jsonl`, or empty) would resolve the session + // dir to the project dir and trash every session in it. + if (!sessionId || sessionId === '.' || sessionId === '..') { + return null + } + // //, named after the transcript. Taking it rather than just the + // `subagents/` it holds is what stops an empty / being left behind. + // Derived from the scanner's subagents path so the two can't drift. + const sessionDir = dirname(subagentTranscriptsDirFor(resolvedPath)) + // Derived from the matched root, so a WSL-home session cleans up that + // distro's session-env rather than the local host's. + const sessionEnvRoot = join(dirname(matchedRoot), 'session-env') + return [ + { path: sessionDir, kind: 'directory', roots }, + { path: join(sessionEnvRoot, sessionId), kind: 'directory', roots: [sessionEnvRoot] }, + { path: resolvedPath, kind: 'file', roots } + ] + } + + return [{ path: resolvedPath, kind: 'file', roots }] +} + +function rejected( + agent: AiVaultAgent, + reason: AiVaultSessionDeleteRejectionCode +): AiVaultSessionDeleteValidationResult { + return { allowed: false, agent, reason } +} diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts new file mode 100644 index 00000000000..eb25b11a820 --- /dev/null +++ b/src/main/ai-vault/session-delete.test.ts @@ -0,0 +1,293 @@ +import { join } from 'node:path' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { lstatMock, realpathMock, trashItemMock, tryDeleteWslUncPathMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + realpathMock: vi.fn(), + trashItemMock: vi.fn(), + tryDeleteWslUncPathMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + realpath: realpathMock +})) + +vi.mock('electron', () => ({ + shell: { trashItem: trashItemMock } +})) + +vi.mock('../wsl-unc-delete', () => ({ + tryDeleteWslUncPath: tryDeleteWslUncPathMock +})) + +import { deleteAiVaultSessionFile } from './session-delete' + +const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') +const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') +const CLAUDE_ROOT = join(HOME, '.claude', 'projects') +const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') + +function enoent(): NodeJS.ErrnoException { + const error = new Error('not found') as NodeJS.ErrnoException + error.code = 'ENOENT' + return error +} + +function baseArgs(filePath: string) { + return { + agent: 'gemini' as const, + filePath, + executionHostId: 'local' as const, + rootOptions: { geminiSessionsDir: GEMINI_ROOT } + } +} + +describe('deleteAiVaultSessionFile', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: not a WSL UNC path, so the fs-guard branch runs. + tryDeleteWslUncPathMock.mockResolvedValue(false) + }) + + it('trashes a regular file whose realpath matches the resolved path', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('rejects a directory instead of trashing it', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a symlink instead of trashing it (isFile() is false for a symlink under lstat)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => false }) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('rejects a regular file whose realpath escapes the known roots', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const escaped = join(HOME, 'Documents', 'escaped.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + // The file's realpath escapes; the root realpaths to itself (not symlinked). + realpathMock.mockImplementation((p: string) => Promise.resolve(p === filePath ? escaped : p)) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'path-outside-known-roots' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('accepts a session under a symlinked root by realpath-resolving the roots too', async () => { + // GEMINI_ROOT is a symlink to a real target; the file's realpath lands under + // the real target, which the text-only root would not match. Realpath-ing + // the root as well keeps this legit delete from a false rejection. + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + const realRoot = join('/real', '.gemini', 'tmp') + const realFile = join(realRoot, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockImplementation((p: string) => + Promise.resolve(p === GEMINI_ROOT ? realRoot : p === filePath ? realFile : p) + ) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(filePath) + }) + + it('treats ENOENT from lstat as an idempotent success', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('treats ENOENT from trashItem as an idempotent success (race with an external delete)', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(enoent()) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + }) + + it('returns a failure result when trashItem throws a non-ENOENT error', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + lstatMock.mockResolvedValue({ isFile: () => true }) + realpathMock.mockResolvedValue(filePath) + trashItemMock.mockRejectedValue(new Error('permission denied')) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'failed', agent: 'gemini', error: 'permission denied' }) + }) + + it('short-circuits a rejected validation (unsupported agent) before touching the filesystem', async () => { + const filePath = join(HOME, '.codex', 'sessions', 'rollout-1.jsonl') + + const result = await deleteAiVaultSessionFile({ + agent: 'codex', + filePath, + executionHostId: 'local' + }) + + expect(result).toEqual({ outcome: 'rejected', agent: 'codex', reason: 'unsupported-agent' }) + expect(lstatMock).not.toHaveBeenCalled() + expect(realpathMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + expect(tryDeleteWslUncPathMock).not.toHaveBeenCalled() + }) + + it('delegates a WSL UNC file to tryDeleteWslUncPath (non-recursive) and never calls trashItem', async () => { + const filePath = join(GEMINI_ROOT, 'project-a', 'session-1.json') + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile(baseArgs(filePath)) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(filePath, { recursive: false }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it('deletes a WSL UNC directory-shaped session recursively, never via trashItem', async () => { + // The bug this guards: a directory removal (rovo/grok/claude dir) on a WSL + // UNC path used to skip the WSL branch and hit shell.trashItem, which + // cannot trash a WSL volume item — so it failed on Windows or was silently + // stranded by the 9P filesystem's unreliable lstat. + tryDeleteWslUncPathMock.mockResolvedValue(true) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(ROVO_ROOT, 'sess-1', 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(join(ROVO_ROOT, 'sess-1'), { + recursive: true + }) + expect(lstatMock).not.toHaveBeenCalled() + expect(trashItemMock).not.toHaveBeenCalled() + }) + + // A session whose delete unit is a path set. What matters here is the + // order — the transcript is what puts the row on screen, so it must be the + // last thing removed. + describe('directory-shaped agents', () => { + const claudeArgs = { + agent: 'claude' as const, + filePath: join(CLAUDE_ROOT, '-proj', 'sess-1.jsonl'), + executionHostId: 'local' as const, + rootOptions: { claudeProjectsDir: CLAUDE_ROOT } + } + + it('trashes the subagents and session-env dirs before the transcript', async () => { + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path.endsWith('.jsonl') ? { isFile: () => true } : { isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([ + join(CLAUDE_ROOT, '-proj', 'sess-1'), + join(HOME, '.claude', 'session-env', 'sess-1'), + claudeArgs.filePath + ]) + }) + + it('still deletes when a companion is absent — most sessions spawn no subagent', async () => { + lstatMock.mockImplementation((path: string) => + path.endsWith('.jsonl') ? Promise.resolve({ isFile: () => true }) : Promise.reject(enoent()) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock.mock.calls.map(([path]) => path)).toEqual([claudeArgs.filePath]) + }) + + it('leaves the transcript in place when a companion removal is rejected', async () => { + // A file where the plan expects the session directory: refuse rather + // than coerce, and stop before the transcript so the row stays to retry. + lstatMock.mockImplementation((path: string) => + Promise.resolve( + path === join(CLAUDE_ROOT, '-proj', 'sess-1') + ? { isFile: () => true, isDirectory: () => false } + : { isFile: () => true, isDirectory: () => true } + ) + ) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + + const result = await deleteAiVaultSessionFile(claudeArgs) + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'claude', + reason: 'unexpected-target-kind' + }) + expect(trashItemMock).not.toHaveBeenCalled() + }) + + it("trashes rovo's session directory rather than the metadata file", async () => { + const sessionDir = join(ROVO_ROOT, 'sess-1') + lstatMock.mockResolvedValue({ isDirectory: () => true }) + realpathMock.mockImplementation((path: string) => Promise.resolve(path)) + trashItemMock.mockResolvedValue(undefined) + + const result = await deleteAiVaultSessionFile({ + agent: 'rovo', + filePath: join(sessionDir, 'metadata.json'), + executionHostId: 'local', + rootOptions: { rovoSessionsDir: ROVO_ROOT } + }) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(trashItemMock).toHaveBeenCalledWith(sessionDir) + }) + }) +}) diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts new file mode 100644 index 00000000000..d52725ff0fb --- /dev/null +++ b/src/main/ai-vault/session-delete.ts @@ -0,0 +1,104 @@ +import { lstat, realpath } from 'node:fs/promises' +import { shell } from 'electron' +import type { + AiVaultDeleteSessionResult, + AiVaultSessionDeleteRemoval +} from '../../shared/ai-vault-session-deletion' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + validateAiVaultSessionDeleteTarget, + type ValidateAiVaultSessionDeleteTargetArgs +} from './session-delete-target' +import { tryDeleteWslUncPath } from '../wsl-unc-delete' +import { isENOENT } from '../ipc/filesystem-auth' + +// Trashes a validated session's paths, companions first so a part-way failure +// leaves the row on screen to retry from instead of stranding the rest on disk. +// Never throws: IPC payloads are untyped at runtime, so a bad input and an fs +// error both resolve to a discriminated result the handler can render. +export async function deleteAiVaultSessionFile( + args: ValidateAiVaultSessionDeleteTargetArgs +): Promise { + const validation = validateAiVaultSessionDeleteTarget(args) + if (!validation.allowed) { + return { outcome: 'rejected', agent: validation.agent, reason: validation.reason } + } + const { agent, removals } = validation + + try { + for (const removal of removals) { + const rejection = await removeOne(removal) + if (rejection) { + return { outcome: 'rejected', agent, reason: rejection } + } + } + return { outcome: 'deleted' } + } catch (error) { + return { + outcome: 'failed', + agent, + error: error instanceof Error ? error.message : String(error) + } + } +} + +// Trash one planned path, or return the rejection code that stops the plan. A +// missing path is success everywhere: companions are optional (no subagents dir +// if none ever ran) and an externally-deleted transcript must stay idempotent. +async function removeOne( + removal: AiVaultSessionDeleteRemoval +): Promise<'unexpected-target-kind' | 'path-outside-known-roots' | null> { + // First, because a WSL UNC path defeats both guards below: 9P's stat is + // unreliable and the volume has no Recycle Bin for trashItem. Its own + // `rm -f`/`-rf` is idempotent and never follows a symlink. + if (await tryDeleteWslUncPath(removal.path, { recursive: removal.kind === 'directory' })) { + return null + } + + let stats + try { + stats = await lstat(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // lstat (not stat) so a symlink is caught here rather than dereferenced. + const kindMatches = removal.kind === 'file' ? stats.isFile() : stats.isDirectory() + if (!kindMatches) { + return 'unexpected-target-kind' + } + + let realResolvedPath: string + try { + realResolvedPath = await realpath(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + // Catches a symlinked parent that escapes the agent's roots, which lstat on + // the leaf can't see. Roots are only resolve()'d, so realpath them too or a + // session under a symlinked root (~/.claude -> /Volumes/data/.claude) is + // falsely rejected; a root absent on this host falls back to its text form. + if (realResolvedPath !== removal.path) { + const realRoots = await Promise.all( + removal.roots.map((root) => realpath(root).catch(() => root)) + ) + if (!realRoots.some((root) => isPathInsideOrEqual(root, realResolvedPath))) { + return 'path-outside-known-roots' + } + } + + try { + await shell.trashItem(removal.path) + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + return null +} diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts new file mode 100644 index 00000000000..33320498b44 --- /dev/null +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -0,0 +1,254 @@ +import { homedir } from 'node:os' +import { basename, dirname, extname, join, relative } from 'node:path' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' +import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' +import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' +import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' +import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' +import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' +import type { AiVaultScanOptions } from './session-scanner-types' +import { normalizeAgentSessionsDir } from './session-scanner-values' + +export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') +const CODEX_SESSIONS_DIR = join( + process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR, + 'sessions' +) +const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') +const COPILOT_SESSIONS_DIR = join( + process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), + 'session-state' +) +const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') +const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') +const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +// Why: Devin ATIF transcripts are stored under /transcripts. +const DEVIN_TRANSCRIPTS_DIR = join( + process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), + 'transcripts' +) +const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') +const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') + +/** + * Where one agent's session files live and which of them count as sessions. + * + * The scanner walks these roots to list sessions; the delete validator accepts + * only paths this same shape would have surfaced. Both read this one table, so + * "deletable" and "discoverable" cannot drift apart. + */ +export type AiVaultAgentSource = { + // A function, not a value: grok resolves its root lazily because a + // module-scope call binds across chunks at init time and breaks on bundle + // ordering. Returns the local host root plus one per WSL distro home. + rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] + extensions: readonly string[] + filePredicate?: (filePath: string) => boolean + // Return false to skip a directory; depth 0 is a child of the root. + directoryPredicate?: (name: string, depth: number) => boolean + // Roots that are alternates for one install rather than distinct locations, + // so the per-agent limit applies across them rather than to each. + mergeRootDiscoveries?: boolean +} + +// Every deletable agent needs an entry — the delete validator derives its roots +// and its accept rule from this table alone, so adding an agent to +// AI_VAULT_DELETABLE_AGENTS without a source here is a type error. +type AiVaultAgentSourceTable = Record & + Partial> + +// Agents absent from this table are discovered by shape-specific scanners +// instead: opencode (SQLite plus legacy files) and antigravity (brain dirs). +export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { + claude: { + rootDirs: (options, wslHomeDirs) => + claudeProjectsRootDirs({ claudeProjectsDir: options.claudeProjectsDir, wslHomeDirs }), + extensions: ['.jsonl'], + // Why: Task subagent transcripts under `/subagents/` share the parent + // sessionId and aren't independently resumable, so they'd just duplicate the + // parent as untitled rows; prune the subtree and read them on demand under + // their parent instead. + directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + }, + codex: { + rootDirs: (options, wslHomeDirs) => + uniqueCodexSessionsDirs([ + options.codexSessionsDir ?? CODEX_SESSIONS_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')), + // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME, + // not the user's default ~/.codex history root. + ...wslHomeDirs.map((homeDir) => + join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions') + ), + ...(options.additionalCodexSessionsDirs ?? []) + ]), + extensions: ['.jsonl'] + }, + gemini: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ + '.gemini', + 'tmp' + ]), + extensions: ['.json', '.jsonl'] + }, + copilot: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, wslHomeDirs, [ + '.copilot', + 'session-state' + ]), + extensions: ['.jsonl'] + }, + cursor: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ + '.cursor', + 'projects' + ]), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts') + }, + grok: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ + '.grok', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'summary.json' + }, + devin: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ + '.local', + 'share', + 'devin', + 'cli', + 'transcripts' + ]), + extensions: ['.json'] + }, + hermes: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ + '.hermes', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath).startsWith('session_') + }, + rovo: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [ + '.rovodev', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: (filePath) => basename(filePath) === 'metadata.json' + }, + pi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ + '.pi', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'] + }, + omp: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]), + extensions: ['.jsonl'], + // Why: task subagent transcripts live inside the session's same-named + // artifact directory (`_/`); surfaced as top-level rows they + // drown coordinators under their own workers (#9330). Prune the subtree + // and read them on demand under their parent instead. Unlike Claude's, + // these carry their own sessionId and would resume by path — but OMP's + // own picker only globs `*/*.jsonl`, so it never offers them either. + // Depth 0 is the workspace dir, which is never an artifact dir. + directoryPredicate: (name, depth) => depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) + }, + openclaw: { + // Sessions live under /agents; a stateDir already ending in + // `agents` is used as-is. The current and legacy state dirs are the same + // install, so their discoveries merge. + rootDirs: (options, wslHomeDirs) => + [ + options.openclawStateDir ?? OPENCLAW_STATE_DIR, + options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot'), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.openclaw')), + ...wslHomeDirs.map((homeDir) => join(homeDir, '.clawdbot')) + ].map((stateDir) => (basename(stateDir) === 'agents' ? stateDir : join(stateDir, 'agents'))), + extensions: ['.jsonl'], + filePredicate: (filePath) => pathSegments(filePath).includes('sessions'), + mergeRootDiscoveries: true + }, + droid: { + rootDirs: (options, wslHomeDirs) => [ + ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ + '.factory', + 'sessions' + ]), + ...sessionRootDirs(options.droidProjectsDir ?? DROID_PROJECTS_DIR, wslHomeDirs, [ + '.factory', + 'projects' + ]) + ], + extensions: ['.jsonl'] + }, + kimi: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ + '.kimi-code', + 'sessions' + ]), + extensions: ['.json'], + // Why: each Kimi session is /wd_*/session_*/state.json; match + // only those (not the sibling agents/*/wire.jsonl transcripts). + filePredicate: (filePath) => + basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_') + } +} + +/** + * Whether a scan rooted at `rootDir` would surface `filePath`: its extension, + * its file predicate, and every directory between the two passing the directory + * predicate. This is the delete validator's accept rule, so a path no scan + * would ever list can't become a delete target either. + */ +export function isDiscoverableSessionFile( + source: AiVaultAgentSource, + rootDir: string, + filePath: string +): boolean { + if (!source.extensions.includes(extname(filePath).toLowerCase())) { + return false + } + if (source.filePredicate && !source.filePredicate(filePath)) { + return false + } + const { directoryPredicate } = source + if (!directoryPredicate) { + return true + } + // Indexed like walkSessionFiles: depth 0 is a child of rootDir. + return pathSegments(relative(rootDir, dirname(filePath))) + .filter(Boolean) + .every((name, depth) => directoryPredicate(name, depth)) +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]/) +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index d93d6f01fea..39398fd4d09 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,5 +1,5 @@ import { readdir, stat } from 'node:fs/promises' -import { basename, delimiter, extname, join } from 'node:path' +import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -42,30 +42,6 @@ export async function discoverFiles(args: { } } -export async function discoverOpenClawFiles(args: { - rootDirs: string[] - limit: number - issues: AiVaultScanIssue[] -}): Promise { - const discoveries = await Promise.all( - args.rootDirs.map((rootDir) => - discoverFiles({ - rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), - limit: args.limit, - agent: 'openclaw', - issues: args.issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('sessions') - }) - ) - ) - const files = discoveries - .flatMap((discovery) => discovery.files) - .sort((left, right) => right.mtimeMs - left.mtimeMs) - .slice(0, args.limit) - return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } -} - export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts b/src/main/ai-vault/session-scanner-droid-kimi-sources.ts deleted file mode 100644 index 57ad71f0681..00000000000 --- a/src/main/ai-vault/session-scanner-droid-kimi-sources.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { homedir } from 'node:os' -import { basename, dirname, join } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { discoverFiles } from './session-scanner-discovery' -import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' -import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' - -const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') - -export function droidDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return [ - ...sessionRootDirs(options.droidSessionsDir ?? DROID_SESSIONS_DIR, wslHomeDirs, [ - '.factory', - 'sessions' - ]), - ...sessionRootDirs( - options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'), - wslHomeDirs, - ['.factory', 'projects'] - ) - ].map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'droid', issues, extensions: ['.jsonl'] }) - ) -} - -export function kimiDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ - '.kimi-code', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'kimi', - issues, - extensions: ['.json'], - // Why: each Kimi session is /wd_*/session_*/state.json; match - // only those (not the sibling agents/*/wire.jsonl transcripts). - filePredicate: (path) => - basename(path) === 'state.json' && basename(dirname(path)).startsWith('session_') - }) - ) -} - -function sessionRootDirs( - hostRootDir: string, - wslHomeDirs: readonly string[], - segments: readonly string[] -): string[] { - return [hostRootDir, ...wslHomeDirs.map((homeDir) => join(homeDir, ...segments))] -} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 7d9068f8e39..8c11557e244 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -97,6 +97,12 @@ export function resetSessionParseCacheForTests(): void { cache.clear() } +// Drops one entry after its file is deleted. Cleanliness, not correctness: +// discovery walks disk first, so a trashed file is never rediscovered anyway. +export function invalidateSessionParseCacheEntry(path: string): void { + cache.delete(path) +} + // Persisted subset of a cache entry: the non-serializable `resume` parser // state is dropped (see session-parse-cache-persistence.ts). export type PersistedSessionParseCacheEntry = Omit diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index ba5d253fc8b..fd368f6c172 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -1,44 +1,13 @@ -import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' -import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' -import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' -import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' -import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' +import { delimiter } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' import { opencodeDiscoveries } from './session-scanner-opencode-sources' -import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizeAgentSessionsDir } from './session-scanner-values' -import { - claudeProjectsRootDirs, - normalizedWslHomeDirs, - OMP_SESSIONS_DIR, - sessionRootDirs -} from './session-scanner-roots' -import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' +import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources' +import { normalizedWslHomeDirs } from './session-scanner-roots' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') -const CODEX_HOME_DIR = process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR -const CODEX_SESSIONS_DIR = join(CODEX_HOME_DIR, 'sessions') -const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') -const COPILOT_SESSIONS_DIR = join( - process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), - 'session-state' -) -const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') -const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') -const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizeAgentSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), - '.pi' -) -// Why: Devin ATIF transcripts are stored under /transcripts. -const DEVIN_TRANSCRIPTS_DIR = join( - process.env.DEVIN_HOME?.trim() || join(homedir(), '.local', 'share', 'devin', 'cli'), - 'transcripts' -) +export { DEFAULT_CODEX_HOME_DIR } from './session-scanner-agent-sources' export async function discoverAiVaultSessionSources(args: { options: AiVaultScanOptions @@ -47,258 +16,64 @@ export async function discoverAiVaultSessionSources(args: { }): Promise { const { options, limitPerAgent, issues } = args const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs) - const codexSessionsDirs = uniqueCodexSessionsDirs([ - options.codexSessionsDir ?? CODEX_SESSIONS_DIR, - ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')), - // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME, - // not the user's default ~/.codex history root. - ...wslHomeDirs.map((homeDir) => - join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions') - ), - ...(options.additionalCodexSessionsDirs ?? []) - ]) return Promise.all([ // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), - ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - openClawDiscovery(options, wslHomeDirs, limitPerAgent, issues), - ...droidDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...kimiDiscoveries(options, wslHomeDirs, limitPerAgent, issues) + ...antigravityDiscoveries(options, wslHomeDirs, limitPerAgent, issues), + ...Object.entries(AI_VAULT_AGENT_SOURCES).flatMap(([agent, source]) => + source + ? agentDiscoveries( + agent as AiVaultAgent, + source, + options, + wslHomeDirs, + limitPerAgent, + issues + ) + : [] + ) ]) } -function claudeDiscoveries( +function agentDiscoveries( + agent: AiVaultAgent, + source: AiVaultAgentSource, options: AiVaultScanOptions, wslHomeDirs: readonly string[], limit: number, issues: AiVaultScanIssue[] ): Promise[] { - return claudeProjectsRootDirs({ - claudeProjectsDir: options.claudeProjectsDir, - wslHomeDirs - }).map((rootDir) => + const rootDirs = source.rootDirs(options, wslHomeDirs) + const discover = (rootDir: string): Promise => discoverFiles({ rootDir, limit, - agent: 'claude', + agent, issues, - extensions: ['.jsonl'], - // Why: Task subagent transcripts under `/subagents/` share the parent - // sessionId and aren't independently resumable, so they'd just duplicate the - // parent as untitled rows; prune the subtree and read them on demand under - // their parent instead. - directoryPredicate: (name) => name !== SUBAGENT_DIR_NAME + extensions: [...source.extensions], + filePredicate: source.filePredicate, + directoryPredicate: source.directoryPredicate }) - ) + return source.mergeRootDiscoveries + ? [mergedDiscovery(agent, rootDirs, limit, discover)] + : rootDirs.map(discover) } -function codexDiscoveries( +// Alternate roots for one install: the limit spans them, so they collapse into +// a single discovery rather than each claiming a full slice of it. +async function mergedDiscovery( + agent: AiVaultAgent, rootDirs: readonly string[], limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return rootDirs.map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'codex', issues, extensions: ['.jsonl'] }) - ) -} - -function standardDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return [ - ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), - ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ - '.gemini', - 'tmp' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'gemini', issues, extensions: ['.json', '.jsonl'] }) - ), - ...sessionRootDirs(options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, wslHomeDirs, [ - '.copilot', - 'session-state' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) - ), - ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...grokDiscoveries(options, wslHomeDirs, limit, issues), - ...devinDiscoveries(options, wslHomeDirs, limit, issues), - ...hermesDiscoveries(options, wslHomeDirs, limit, issues), - ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues), - ...ompDiscoveries(options, wslHomeDirs, limit, issues) - ] -} - -function cursorDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, wslHomeDirs, [ - '.cursor', - 'projects' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'cursor', - issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts') - }) - ) -} - -function grokDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - // Resolved lazily: a module-scope call binds across chunks at init time, which - // breaks whenever bundle ordering puts this module before its import. - return sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ - '.grok', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'grok', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path) === 'summary.json' - }) - ) -} - -function devinDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.devinTranscriptsDir ?? DEVIN_TRANSCRIPTS_DIR, wslHomeDirs, [ - '.local', - 'share', - 'devin', - 'cli', - 'transcripts' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'devin', issues, extensions: ['.json'] }) - ) -} - -function hermesDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, wslHomeDirs, [ - '.hermes', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'hermes', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path).startsWith('session_') - }) - ) -} - -function rovoDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, wslHomeDirs, [ - '.rovodev', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'rovo', - issues, - extensions: ['.json'], - filePredicate: (path) => basename(path) === 'metadata.json' - }) - ) -} - -function piDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.piSessionsDir ?? PI_SESSIONS_DIR, wslHomeDirs, [ - '.pi', - 'agent', - 'sessions' - ]).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'pi', issues, extensions: ['.jsonl'] }) - ) -} - -function ompDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ - '.omp', - 'agent', - 'sessions' - ]).map((rootDir) => - discoverFiles({ - rootDir, - limit, - agent: 'omp', - issues, - extensions: ['.jsonl'], - // Why: task subagent transcripts live inside the session's same-named - // artifact directory (`_/`); surfaced as top-level rows they - // drown coordinators under their own workers (#9330). Prune the subtree - // and read them on demand under their parent instead. Unlike Claude's, - // these carry their own sessionId and would resume by path — but OMP's - // own picker only globs `*/*.jsonl`, so it never offers them either. - // Depth 0 is the workspace dir, which is never an artifact dir. - directoryPredicate: (name, depth) => - depth === 0 || !OMP_SESSION_ARTIFACT_DIR_PATTERN.test(name) - }) - ) -} - -function openClawDiscovery( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] + discover: (rootDir: string) => Promise ): Promise { - return discoverOpenClawFiles({ - rootDirs: [ - options.openclawStateDir ?? OPENCLAW_STATE_DIR, - options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot'), - ...wslHomeDirs.map((homeDir) => join(homeDir, '.openclaw')), - ...wslHomeDirs.map((homeDir) => join(homeDir, '.clawdbot')) - ], - limit, - issues - }) + const discoveries = await Promise.all(rootDirs.map(discover)) + const files = discoveries + .flatMap((discovery) => discovery.files) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, limit) + return { agent, rootDir: rootDirs.join(delimiter), files } } diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts new file mode 100644 index 00000000000..de3507d034e --- /dev/null +++ b/src/main/ipc/ai-vault-delete.ts @@ -0,0 +1,57 @@ +import { ipcMain } from 'electron' +import { + getAiVaultWslHomeDirs, + invalidateAiVaultSessionListCache +} from '../ai-vault/cached-session-list' +import { deleteAiVaultSessionFile } from '../ai-vault/session-delete' +import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' + +// Which cache backs the multi-host list is ai-vault.ts's concern, so its +// invalidation is injected rather than reached into from here. +type AiVaultDeleteDeps = { + invalidateMultiHostListCache: () => void +} + +// Binds the delete orchestration to the caller's cache-invalidation seam. +export function registerAiVaultDeleteHandler(deps: AiVaultDeleteDeps): void { + ipcMain.handle('aiVault:deleteSession', (_event, args?: AiVaultDeleteSessionArgs) => + deleteAiVaultSession(args, deps) + ) +} + +// Adapts the untyped IPC payload for the executor (which owns re-validation and +// the trash), then invalidates on a real delete — otherwise the caches keep +// serving the deleted session for up to the scan TTL. +export async function deleteAiVaultSession( + args: AiVaultDeleteSessionArgs | undefined, + deps: AiVaultDeleteDeps +): Promise { + // The validator tolerates a malformed agent/filePath but destructures `args`, + // so an absent payload is defaulted here to keep the never-throws boundary. + const wslHomeDirs = await getAiVaultWslHomeDirs() + const result = await deleteAiVaultSessionFile({ + agent: args?.agent as AiVaultAgent, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }) + + if (result.outcome === 'deleted') { + // Three caches could otherwise resurrect it: the desktop per-host leg + // cache, the shared local-scope cache (also the runtime/mobile path), and + // this file's parse-cache entry. + deps.invalidateMultiHostListCache() + invalidateAiVaultSessionListCache() + // The parse cache is keyed by the raw path the scanner discovered (which is + // what the renderer echoes back as filePath), so invalidate with that exact + // key — resolve() could normalise it away from the stored key and miss. + invalidateSessionParseCacheEntry(args?.filePath ?? '') + } + + return result +} diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 9911e81e98b..18b75517d6d 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -18,6 +18,11 @@ type CachedHostLeg = { // open. Legs are cached individually and the failing host is the only one // rescanned. const cachedHostLegs = new Map() +// Bumped on every invalidation. A leg scan that started before an invalidation +// carries the old generation and must not write its (now stale) result back — +// otherwise a delete's invalidation is silently undone by a scan that resolves +// just after it. +let cacheGeneration = 0 /** Serves one host's leg from the per-host TTL cache, and stores it only when * that host answered without a host issue — a failing host is retried on the @@ -30,6 +35,7 @@ export async function scanHostLegWithCache(args: { scan: () => Promise }): Promise { const now = Date.now() + const startGeneration = cacheGeneration const cached = cachedHostLegs.get(args.cacheKey) if ( !args.force && @@ -40,6 +46,11 @@ export async function scanHostLegWithCache(args: { return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) } const result = await args.scan() + // A delete's invalidation landed while this leg was scanning; caching a + // pre-delete result would resurrect the deleted session for the TTL. + if (startGeneration !== cacheGeneration) { + return result + } if (result.issues.some((issue) => issue.kind === 'host')) { const current = cachedHostLegs.get(args.cacheKey) if (!current || current.expiresAt <= Date.now()) { @@ -73,6 +84,15 @@ function pruneExpiredHostLegs(now: number): void { } } -export function resetAiVaultHostLegCacheForTests(): void { +// Drops every cached host leg after a session is deleted so a non-force list +// call within the TTL can't still serve the trashed session. Scan discovery +// walks disk first, so a deleted file is never rediscovered — this only guards +// the cached RESULT. +export function invalidateAiVaultHostLegCache(): void { + cacheGeneration++ cachedHostLegs.clear() } + +export function resetAiVaultHostLegCacheForTests(): void { + invalidateAiVaultHostLegCache() +} diff --git a/src/main/ipc/ai-vault-subagent-list.ts b/src/main/ipc/ai-vault-subagent-list.ts new file mode 100644 index 00000000000..49f609577c7 --- /dev/null +++ b/src/main/ipc/ai-vault-subagent-list.ts @@ -0,0 +1,51 @@ +import { resolve } from 'node:path' +import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' +import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' +import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' +import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { + AiVaultSubagentListArgs, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' + +// Provider-gated: only Claude and OMP materialize Task subagent transcripts as +// sibling files today; other agents resolve to an empty list. +export async function listAiVaultSubagentSessions( + args?: AiVaultSubagentListArgs +): Promise { + // IPC payloads are untyped at runtime; malformed input resolves empty like + // every other rejected input instead of throwing. + if ( + !args || + (args.agent !== 'claude' && args.agent !== 'omp') || + typeof args.parentFilePath !== 'string' || + !args.parentFilePath.trim() + ) { + return { sessions: [], issues: [] } + } + // Why: subagent transcripts are read from the local filesystem. The UI + // skips remote sessions (their transcripts live on the remote host); return + // empty defensively rather than reading local paths for a remote session. + if ((args.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID) { + return { sessions: [], issues: [] } + } + // Why: the path is renderer-supplied; only list files under the agent's + // known sessions roots so a crafted path can't readdir/preview arbitrary + // dirs. + // resolve() collapses `..` segments first — isPathInsideOrEqual compares + // textually and would otherwise pass `/../../etc/x.jsonl`. + const parentFilePath = resolve(args.parentFilePath) + const wslHomeDirs = await getAiVaultWslHomeDirs() + const roots = + args.agent === 'claude' + ? claudeProjectsRootDirs({ wslHomeDirs }) + : ompSessionsRootDirs({ wslHomeDirs }) + if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { + return { sessions: [], issues: [] } + } + return args.agent === 'claude' + ? listClaudeSubagentSessions({ parentFilePath }) + : listOmpSubagentSessions({ parentFilePath }) +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 82f3585e7e4..ac0427e061e 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -3,6 +3,8 @@ import { join, sep } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' +import type * as CachedSessionListModule from '../ai-vault/cached-session-list' +import type * as SessionParseCacheModule from '../ai-vault/session-scanner-parse-cache' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' @@ -17,7 +19,10 @@ const mocks = vi.hoisted(() => ({ getActiveSshAiVaultHostInfo: vi.fn(), getActiveSshAiVaultHostInfos: vi.fn(), requestActiveSshAiVaultSessionList: vi.fn(), - ipcHandle: vi.fn() + ipcHandle: vi.fn(), + deleteAiVaultSessionFile: vi.fn(), + invalidateAiVaultSessionListCache: vi.fn(), + invalidateSessionParseCacheEntry: vi.fn() })) vi.mock('electron', () => ({ @@ -41,6 +46,29 @@ vi.mock('../ai-vault/session-scanner-omp-subagent-listing', () => ({ listOmpSubagentSessions: mocks.listOmpSubagentSessions })) +vi.mock('../ai-vault/session-delete', () => ({ + deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile +})) + +// Why: only the invalidation seam is replaced — everything else (cachedList, +// listAiVaultSessions, ...) keeps its real implementation so the existing +// host-routing/caching tests below stay exercising real behavior. +vi.mock('../ai-vault/cached-session-list', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + invalidateAiVaultSessionListCache: mocks.invalidateAiVaultSessionListCache + } +}) + +vi.mock('../ai-vault/session-scanner-parse-cache', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + invalidateSessionParseCacheEntry: mocks.invalidateSessionParseCacheEntry + } +}) + vi.mock('../wsl', () => ({ getWslHomeAsync: mocks.getAiVaultWslHomeDirs, listWslDistrosAsync: vi.fn().mockResolvedValue([]) @@ -674,6 +702,124 @@ describe('listAiVaultSubagentSessions gating', () => { }) }) +describe('deleteAiVaultSession', () => { + const args = { + agent: 'gemini' as const, + filePath: '/home/ada/.gemini/tmp/sess.json', + executionHostId: 'local' as const + } + + it('invalidates every AI Vault cache after a real delete', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + + const result = await _internals.deleteAiVaultSession(args) + + expect(result).toEqual({ outcome: 'deleted' }) + expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'gemini', + filePath: args.filePath, + executionHostId: 'local' + }) + ) + expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) + expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) + }) + + it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'rejected', + agent: 'gemini', + reason: 'non-local-host' + }) + + const result = await _internals.deleteAiVaultSession({ + ...args, + executionHostId: 'ssh:dev-box' + }) + + expect(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'non-local-host' }) + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() + }) + + it('does not invalidate any cache when the executor fails', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'failed', + agent: 'gemini', + error: 'EPERM' + }) + + await _internals.deleteAiVaultSession(args) + + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + expect(mocks.invalidateSessionParseCacheEntry).not.toHaveBeenCalled() + }) + + it('resolves a malformed payload to a rejection instead of throwing', async () => { + mocks.deleteAiVaultSessionFile.mockResolvedValue({ + outcome: 'rejected', + agent: undefined, + reason: 'invalid-path' + }) + + await expect(_internals.deleteAiVaultSession(undefined)).resolves.toEqual({ + outcome: 'rejected', + agent: undefined, + reason: 'invalid-path' + }) + expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '' }) + ) + expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() + }) + + it('registers the aiVault:deleteSession IPC channel', () => { + registerAiVaultHandlers() + + expect(mocks.ipcHandle).toHaveBeenCalledWith('aiVault:deleteSession', expect.any(Function)) + }) + + // Observes the real multi-host invalidation seam (not mocked) end-to-end: + // deleting must clear this module's own cache, not only the two shared caches. + it('clears the multi-host scan-result cache after a real delete', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + // Second list is a cache hit, so only one scan so far. + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + await _internals.deleteAiVaultSession(args) + + // Cache was cleared, so the next list re-scans instead of serving the stale + // list that still contained the deleted session. + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + }) + + // Generation guard: a scan already in flight when the delete lands must not + // write its pre-delete result back into the multi-host cache. + it('does not let an in-flight multi-host scan repopulate the cache after a delete', async () => { + let resolveScan: (value: AiVaultListResult) => void = () => {} + mocks.scanRemoteAiVaultSessions.mockReturnValueOnce( + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const inFlight = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) + await _internals.deleteAiVaultSession(args) + + resolveScan(result([session('ssh:dev-box', 'stale-session')])) + await inFlight + + // The late scan must not have cached its stale result: the next list re-scans. + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) + }) +}) + function hostInfo(targetId: string) { return { targetId, diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f3e4c16151f..4db28cf3cef 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,16 +1,12 @@ import { app, ipcMain } from 'electron' -import { resolve } from 'node:path' import { configureAiVaultSessionSources, - getAiVaultWslHomeDirs, listAiVaultSessions as listCachedLocalAiVaultSessions, resetAiVaultSessionListCacheForTests, type AiVaultSessionSources } from '../ai-vault/cached-session-list' -import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' -import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' -import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { deleteAiVaultSession, registerAiVaultDeleteHandler } from './ai-vault-delete' +import { listAiVaultSubagentSessions } from './ai-vault-subagent-list' import { aiVaultScanIssueResult, cancelledAiVaultListResult, @@ -18,6 +14,7 @@ import { } from '../ai-vault/session-list-results' import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' +import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, isAiVaultScanCancelledError, @@ -45,7 +42,11 @@ import { type RuntimeAiVaultHostInfo, type RuntimeAiVaultScanner } from './ai-vault-runtime-scan' -import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache' +import { + invalidateAiVaultHostLegCache, + resetAiVaultHostLegCacheForTests, + scanHostLegWithCache +} from './ai-vault-host-leg-cache' import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 @@ -66,6 +67,9 @@ type AiVaultHandlerOptions = AiVaultSessionSources & let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() +// Shared by the IPC registration and the test internals: a delete must drop +// the multi-host leg cache, which this module owns the only caller of. +const aiVaultDeleteDeps = { invalidateMultiHostListCache: invalidateAiVaultHostLegCache } async function listAiVaultSessions( args?: AiVaultListArgs, @@ -286,6 +290,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => handleAiVaultGetFirstUserPrompt(args) ) + registerAiVaultDeleteHandler(aiVaultDeleteDeps) // DOM focus/visibility events don't fire in the renderer on macOS app // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { @@ -295,45 +300,6 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } -// Provider-gated: only Claude and OMP materialize Task subagent transcripts as -// sibling files today; other agents resolve to an empty list. -async function listAiVaultSubagentSessions( - args?: AiVaultSubagentListArgs -): Promise { - // IPC payloads are untyped at runtime; malformed input resolves empty like - // every other rejected input instead of throwing. - if ( - !args || - (args.agent !== 'claude' && args.agent !== 'omp') || - typeof args.parentFilePath !== 'string' || - !args.parentFilePath.trim() - ) { - return { sessions: [], issues: [] } - } - // Why: subagent transcripts are read from the local filesystem. The UI - // skips remote sessions (their transcripts live on the remote host); return - // empty defensively rather than reading local paths for a remote session. - if ((args.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !== LOCAL_EXECUTION_HOST_ID) { - return { sessions: [], issues: [] } - } - // Why: the path is renderer-supplied; only list files under the agent's - // known sessions roots so a crafted path can't readdir/preview arbitrary - // dirs. resolve() collapses `..` segments first — isPathInsideOrEqual - // compares textually and would otherwise pass `/../../etc/x.jsonl`. - const parentFilePath = resolve(args.parentFilePath) - const wslHomeDirs = await getAiVaultWslHomeDirs() - const roots = - args.agent === 'claude' - ? claudeProjectsRootDirs({ wslHomeDirs }) - : ompSessionsRootDirs({ wslHomeDirs }) - if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { - return { sessions: [], issues: [] } - } - return args.agent === 'claude' - ? listClaudeSubagentSessions({ parentFilePath }) - : listOmpSubagentSessions({ parentFilePath }) -} - function resetAiVaultCacheForTests(): void { resetAiVaultHostLegCacheForTests() scanCoordinator = new AiVaultScanCoordinator() @@ -346,5 +312,7 @@ function resetAiVaultCacheForTests(): void { export const _internals = { listAiVaultSessions, listAiVaultSubagentSessions, + deleteAiVaultSession: (args?: AiVaultDeleteSessionArgs) => + deleteAiVaultSession(args, aiVaultDeleteDeps), resetAiVaultCacheForTests } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b1d9feb03c7..a89debcce31 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -499,6 +499,10 @@ import type { OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../shared/opencode-usage-types' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, @@ -923,6 +927,8 @@ export type AiVaultApi = { listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise /** Full first user prompt for copy/reuse (re-parses one transcript). */ getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise + /** Moves a deletable session's transcript to the OS trash; local sessions only. */ + deleteSession: (args: AiVaultDeleteSessionArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 34e195f3848..8c63d111d6d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -242,6 +242,10 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../shared/ai-vault-session-deletion' import type { AiVaultFirstUserPromptArgs, AiVaultListArgs, @@ -4242,6 +4246,8 @@ const api = { ipcRenderer.invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index e048925c734..e5a18bc3014 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -49,6 +49,7 @@ import { import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' +import { useAiVaultSessionDeleteAction } from './ai-vault-session-delete-action' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -304,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { }) }, []) + const requestDelete = useAiVaultSessionDeleteAction({ refresh }) + return (
void requestDelete(session)} /> {launchActions.continuationRequest && ( void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onDelete: () => void }) { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') + const deleteItem = ( + + + {deleteLabel} + + ) return ( <> @@ -126,6 +147,25 @@ export function SessionActionMenuItems({ {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + {deleteBlockedReason ? ( + + + {/* A disabled item is pointer-events:none, so the trigger needs this + wrapper to receive hover (as WorktreeContextMenu does). */} +
{deleteItem}
+
+ + {deleteBlockedReason} + +
+ ) : ( + deleteItem + )} ) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx new file mode 100644 index 00000000000..ac3e3926d80 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { VaultSessionRow } from './AiVaultSessionRow' + +const session = { + id: 'local:gemini:sess-1:/home/a/.gemini/s.json', + executionHostId: 'local', + agent: 'gemini', + sessionId: 'sess-1', + title: 'A session', + cwd: null, + branch: null, + model: null, + filePath: '/home/a/.gemini/s.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: 0, + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'gemini --resume sess-1', + subagent: null +} as unknown as AiVaultSession + +function renderRow(handlers: { onToggleDetails: () => void; onRequestDelete?: () => void }) { + return render( + + + + ) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('VaultSessionRow details toggle', () => { + it('does not expand the row when a menu action is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Expanding here would leave the + // row open behind the confirm dialog, and still open after cancelling. + const onToggleDetails = vi.fn() + const onRequestDelete = vi.fn() + renderRow({ onToggleDetails, onRequestDelete }) + const user = userEvent.setup() + + await user.click(screen.getByTestId('ai-vault-session-more-actions')) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestDelete).toHaveBeenCalledTimes(1) + expect(onToggleDetails).not.toHaveBeenCalled() + }) + + it('still expands when the row itself is clicked', async () => { + const onToggleDetails = vi.fn() + const { container } = renderRow({ onToggleDetails }) + const user = userEvent.setup() + + // The session title: inside the row body, so its click reaches the row's + // own handler — the path a user takes to expand a row. Queried first-match + // because Radix's asChild trigger repeats the subtree. + const title = container.querySelector('[title="Drag to resume in a new tab"]') + expect(title).not.toBeNull() + await user.click(title as Element) + + expect(onToggleDetails).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 416fb2bb79e..9e2f1702f7c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -14,6 +14,7 @@ import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { shouldShowAiVaultSessionWorktreeLine, @@ -51,7 +52,8 @@ export function VaultSessionRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { session: AiVaultSession liveState: AgentStatusState | null @@ -77,10 +79,14 @@ export function VaultSessionRow({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + onRequestDelete: (session: AiVaultSession) => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt const detailsId = getSessionDetailsId(session.id) const latestTurn = latestSessionConversationTurn(session) + // Computed once so the dropdown menu and the context menu never disagree. + const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState) + const requestDelete = (): void => onRequestDelete(session) const detailsTooltip = detailsExpanded ? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details') : translate('auto.components.right.sidebar.AiVaultSessionRow.showDetails', 'Show Details') @@ -120,7 +126,14 @@ export function VaultSessionRow({ 'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55', !detailsExpanded && 'min-h-[98px]' )} - onClick={() => { + onClick={(event) => { + // Radix portals this row's menus out of its DOM, but React still + // bubbles their clicks here — without this, choosing Delete expands + // the row behind the dialog. + const target = event.target + if (target instanceof Node && !event.currentTarget.contains(target)) { + return + } onToggleDetails() }} > @@ -169,6 +182,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onRequestDelete={requestDelete} />
{detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( @@ -233,6 +248,8 @@ export function VaultSessionRow({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={requestDelete} /> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 2139b60b01d..b56311e519b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -66,7 +66,8 @@ export function AiVaultSessionVirtualList({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { groups: readonly AiVaultSessionGroup[] collapsedGroups: ReadonlySet @@ -93,6 +94,7 @@ export function AiVaultSessionVirtualList({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element { const listScrollRef = useRef(null) const stickyRangeStartIndexRef = useRef(0) @@ -229,6 +231,7 @@ export function AiVaultSessionVirtualList({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + onRequestDelete={onRequestDelete} /> ))}
@@ -263,7 +266,8 @@ function AiVaultVirtualRow({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + onRequestDelete }: { row: AiVaultListRow | undefined index: number @@ -291,6 +295,7 @@ function AiVaultVirtualRow({ onOpenLog: (session: AiVaultSession) => void onRevealLog: (session: AiVaultSession) => void onOpenCwd: (session: AiVaultSession) => void + onRequestDelete: (session: AiVaultSession) => void }): React.JSX.Element | null { if (!row) { return null @@ -401,6 +406,7 @@ function AiVaultVirtualRow({ onOpenCwd={ canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined } + onRequestDelete={onRequestDelete} /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx index c013c0b0422..9e48965099b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.test.tsx @@ -23,6 +23,8 @@ function renderActions(onContinueInNewSession?: () => void): string { onContinueInNewSession={onContinueInNewSession} onCopyId={vi.fn()} onCopyPath={vi.fn()} + deleteBlockedReason={null} + onRequestDelete={vi.fn()} /> ) diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 4813930c290..c41c89ef966 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -58,7 +58,9 @@ export function SessionRowTrailingActions({ onCopyPath, onOpenLog, onRevealLog, - onOpenCwd + onOpenCwd, + deleteBlockedReason, + onRequestDelete }: { session: AiVaultSession detailsExpanded: boolean @@ -79,6 +81,9 @@ export function SessionRowTrailingActions({ onOpenLog?: () => void onRevealLog?: () => void onOpenCwd?: () => void + // Null when Delete is offered; otherwise the tooltip explaining why it isn't. + deleteBlockedReason: string | null + onRequestDelete: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) @@ -260,6 +265,8 @@ export function SessionRowTrailingActions({ onOpenLog={onOpenLog} onRevealLog={onRevealLog} onOpenCwd={onOpenCwd} + deleteBlockedReason={deleteBlockedReason} + onDelete={onRequestDelete} />
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts new file mode 100644 index 00000000000..49e8d39e123 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletability' + +// translate() with no loaded catalog returns the English fallback, so these +// assertions pin the English copy as well as the gate order. +const NON_LOCAL = 'Only sessions on this device can be deleted.' +const SYNTHETIC = "This session can't be deleted from Orca." +const LIVE = 'This session is still running — wait for it to finish before deleting.' + +const localGeminiSession = { + agent: 'gemini' as const, + executionHostId: 'local' as const, + filePath: '/home/user/.gemini/sessions/log.jsonl' +} + +describe('aiVaultSessionDeleteBlockedReason', () => { + it('offers Delete for a deletable agent on a local, real path', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession)).toBeNull() + }) + + it('offers Delete for a directory-shaped agent (claude)', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'local', + filePath: '/home/user/.claude/projects/-proj/sess-1.jsonl' + }) + ).toBeNull() + }) + + it('tells the user to wait while the agent is still running', () => { + for (const live of ['working', 'blocked', 'waiting'] as const) { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE) + } + }) + + it('offers Delete for a finished session (done) and one with no live state', () => { + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull() + expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull() + }) + + it('keeps the permanent reason over "running" for an unsupported live session', () => { + // A live but unsupported agent stays "unsupported" — it would never become + // deletable, so "wait for it to finish" would mislead. + expect( + aiVaultSessionDeleteBlockedReason( + { agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' }, + 'working' + ) + ).toBe("Codex sessions can't be deleted from Orca.") + }) + + it('blocks ssh- and runtime-hosted sessions regardless of agent', () => { + for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) { + expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe( + NON_LOCAL + ) + } + }) + + it('blocks a synthetic OpenCode SQLite row identity', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/db.sqlite#sess_123' + }) + ).toBe(SYNTHETIC) + }) + + it('names the agent without explaining why it is unsupported', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'opencode', + executionHostId: 'local', + filePath: '/home/user/.opencode/sessions/log.jsonl' + }) + ).toBe("OpenCode sessions can't be deleted from Orca.") + }) + + it('gives a multi-cause agent (antigravity) the same single sentence', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'antigravity', + executionHostId: 'local', + filePath: '/home/user/.antigravity/brain/conv-1/.system_generated/logs/transcript.jsonl' + }) + ).toBe("Antigravity sessions can't be deleted from Orca.") + }) + + it('prioritizes the host gate over the unsupported-agent reason', () => { + expect( + aiVaultSessionDeleteBlockedReason({ + agent: 'claude', + executionHostId: 'ssh:dev-box', + filePath: '/home/user/.claude/sessions/sess-dir/log.jsonl' + }) + ).toBe(NON_LOCAL) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts new file mode 100644 index 00000000000..033ebd87c81 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-deletability.ts @@ -0,0 +1,60 @@ +import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import { agentLabel } from './ai-vault-session-filters' +import { + canUseLocalAiVaultSessionPathActions, + isSyntheticAiVaultSessionPath +} from './ai-vault-session-path-actions' + +// Matches the active-dot rule in ai-vault-session-row-display. +function isSessionLive(liveState: AgentStatusState | null | undefined): boolean { + return liveState != null && liveState !== 'done' +} + +/** + * Why Delete is unavailable for this session, as the tooltip text to show — or + * null when it is offered. Each message says which sessions are affected, never + * why: a provider's storage layout is Orca's problem, not the reader's. + * + * NOT the security boundary — main re-validates the path on disk regardless. + * The two sides agree on deletable-or-not but deliberately not on the order they + * check, so an SSH session reads as "remote" rather than "unsupported agent". + * What must hold is that renderer-deletable is a subset of main-deletable, and + * it does: both consult the same shared agent set and host/synthetic predicates. + */ +export function aiVaultSessionDeleteBlockedReason( + session: Pick, + liveState?: AgentStatusState | null +): string | null { + if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonNonLocalHost', + 'Only sessions on this device can be deleted.' + ) + } + if (isSyntheticAiVaultSessionPath(session.filePath)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSyntheticPath', + "This session can't be deleted from Orca." + ) + } + if (!isAiVaultDeletableAgent(session.agent)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonUnsupportedAgent', + "{{value0}} sessions can't be deleted from Orca.", + { value0: agentLabel(session.agent) } + ) + } + // Last, so an otherwise-deletable session reads as "wait for it to finish" + // rather than a permanent reason. Trashing a live transcript would drop the + // writes the agent is still appending. + if (isSessionLive(liveState)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive', + 'This session is still running — wait for it to finish before deleting.' + ) + } + return null +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts new file mode 100644 index 00000000000..629165a1a0c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { agentLabel } from './ai-vault-session-filters' + +/** + * Confirms, then deletes an AI Vault session: toasts the outcome and forces a + * refresh so the row goes away immediately. + */ +export function useAiVaultSessionDeleteAction({ + refresh +}: { + refresh: (options: { force: boolean }) => Promise +}): (session: AiVaultSession) => Promise { + const confirm = useConfirmationDialog() + + return useCallback( + async (session: AiVaultSession) => { + const confirmed = await confirm({ + title: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.title', + 'Delete this session?' + ), + description: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.description', + '"{{value0}}" will be deleted. Once deleted, it will no longer be resumable from {{value1}}\'s own command line either.', + { value0: session.title, value1: agentLabel(session.agent) } + ), + confirmLabel: translate( + 'auto.components.right.sidebar.AiVaultSessionDeleteDialog.confirm', + 'Delete' + ), + confirmVariant: 'destructive' + }) + if (!confirmed) { + return + } + try { + const result = await window.api.aiVault.deleteSession({ + agent: session.agent, + filePath: session.filePath, + executionHostId: session.executionHostId + }) + if (result.outcome !== 'deleted') { + // 'rejected' and 'failed' share one message: the specific reason is a + // main-side detail, not something to surface raw. + throw new Error(result.outcome) + } + toast.success( + translate('auto.components.right.sidebar.AiVaultPanel.sessionDeleted', 'Session deleted') + ) + // Main already invalidated its caches; this is only for immediate UX. + void refresh({ force: true }) + } catch { + // A rejected IPC invoke (transport/serialization) lands here too. + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionDeleteFailed', + "Couldn't delete the session" + ) + ) + } + }, + [confirm, refresh] + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts index 5bbbdcf2d1d..cdf7d415671 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -4,6 +4,11 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { isAiVaultSyntheticSessionPath } from '../../../../shared/ai-vault-session-deletion' + +// One definition, shared with main's delete validator; re-exported here under +// the name this module's callers already use. +export { isAiVaultSyntheticSessionPath as isSyntheticAiVaultSessionPath } export function canUseLocalAiVaultSessionPathActions( executionHostId: ExecutionHostId | null | undefined @@ -13,14 +18,6 @@ export function canUseLocalAiVaultSessionPathActions( return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID } -export function isSyntheticAiVaultSessionPath(filePath: string): boolean { - // Why: newer OpenCode sessions use a synthetic `#` - // scanner identity backed by SQLite — not a real filesystem path. A '#' - // session marker never appears in a genuine local transcript path, so it is - // a reliable v1 signal that there is no single file to open in Orca. - return filePath.includes('#') -} - /** * Whether AI Vault `View Log` / `Open Log` can open this session's log inside * Orca as a read-only tab: a non-blank, local, single-file (non-synthetic) @@ -37,5 +34,5 @@ export function canOpenAiVaultSessionLogInOrca( if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) { return false } - return !isSyntheticAiVaultSessionPath(filePath) + return !isAiVaultSyntheticSessionPath(filePath) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3b986240bcf..76cd4dbb92e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11461,7 +11461,9 @@ "openSupportedWorkspace": "Open a workspace before resuming a session.", "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "Session deleted", + "sessionDeleteFailed": "Couldn't delete the session" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -11593,7 +11595,17 @@ "jumpToOriginalPane": "Jump to Original Pane", "jumpToWorktree": "Jump to Worktree", "subagentCountSingular": "1 subagent", - "subagentCountPlural": "{{value0}} subagents" + "subagentCountPlural": "{{value0}} subagents", + "delete": "Delete", + "deleteReasonNonLocalHost": "Only sessions on this device can be deleted.", + "deleteReasonSyntheticPath": "This session can't be deleted from Orca.", + "deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.", + "deleteReasonSessionLive": "This session is still running — wait for it to finish before deleting." + }, + "AiVaultSessionDeleteDialog": { + "title": "Delete this session?", + "description": "\"{{value0}}\" will be deleted. Once deleted, it will no longer be resumable from {{value1}}'s own command line either.", + "confirm": "Delete" }, "FileExplorerNameFilter": { "26fb73c6e3": "Find files", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f0ff1fab3dd..35acb69e71c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "Abre un workspace antes de reanudar una sesión.", "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "Sesión eliminada", + "sessionDeleteFailed": "No se pudo eliminar la sesión" }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "Saltar al worktree", "recoverableBadge": "No guardada", "subagentCountSingular": "1 subagente", - "subagentCountPlural": "{{value0}} subagentes" + "subagentCountPlural": "{{value0}} subagentes", + "delete": "Eliminar", + "deleteReasonNonLocalHost": "Solo se pueden eliminar las sesiones de este dispositivo.", + "deleteReasonSyntheticPath": "Esta sesión no se puede eliminar desde Orca.", + "deleteReasonUnsupportedAgent": "Las sesiones de {{value0}} no se pueden eliminar desde Orca.", + "deleteReasonSessionLive": "Esta sesión aún se está ejecutando: espera a que termine antes de eliminarla." + }, + "AiVaultSessionDeleteDialog": { + "title": "¿Eliminar esta sesión?", + "description": "\"{{value0}}\" se eliminará. Una vez eliminada, tampoco podrá reanudarse desde la línea de comandos propia de {{value1}}.", + "confirm": "Eliminar" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index aaa9248e68a..66b0606eab6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "Open a workspace before resuming a session.", "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "セッションを削除しました", + "sessionDeleteFailed": "セッションを削除できませんでした" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "ワークツリーにジャンプ", "recoverableBadge": "未保存", "subagentCountSingular": "1 件のサブエージェント", - "subagentCountPlural": "{{value0}} 件のサブエージェント" + "subagentCountPlural": "{{value0}} 件のサブエージェント", + "delete": "削除", + "deleteReasonNonLocalHost": "このデバイス上のセッションのみ削除できます。", + "deleteReasonSyntheticPath": "このセッションは Orca では削除できません。", + "deleteReasonUnsupportedAgent": "{{value0}} のセッションは Orca では削除できません。", + "deleteReasonSessionLive": "このセッションはまだ実行中です — 削除する前に完了を待ってください。" + }, + "AiVaultSessionDeleteDialog": { + "title": "このセッションを削除しますか?", + "description": "「{{value0}}」を削除します。削除すると、{{value1}} 自体のコマンドラインからも再開できなくなります。", + "confirm": "削除" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f462a445800..31e25fbb3c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11170,7 +11170,9 @@ "openSupportedWorkspace": "세션을 재개하기 전에 워크스페이스를 여세요.", "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요.", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "Could not prepare this session for resume.", + "sessionDeleted": "세션이 삭제되었습니다", + "sessionDeleteFailed": "세션을 삭제하지 못했습니다" }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -11302,7 +11304,17 @@ "jumpToWorktree": "작업 트리로 이동", "recoverableBadge": "저장 안 됨", "subagentCountSingular": "서브에이전트 1개", - "subagentCountPlural": "서브에이전트 {{value0}}개" + "subagentCountPlural": "서브에이전트 {{value0}}개", + "delete": "삭제", + "deleteReasonNonLocalHost": "이 기기에 있는 세션만 삭제할 수 있습니다.", + "deleteReasonSyntheticPath": "이 세션은 Orca에서 삭제할 수 없습니다.", + "deleteReasonUnsupportedAgent": "{{value0}} 세션은 Orca에서 삭제할 수 없습니다.", + "deleteReasonSessionLive": "이 세션은 아직 실행 중입니다 — 삭제하려면 완료될 때까지 기다리세요." + }, + "AiVaultSessionDeleteDialog": { + "title": "이 세션을 삭제할까요?", + "description": "\"{{value0}}\"이(가) 삭제됩니다. 삭제하면 {{value1}}의 자체 명령줄에서도 더 이상 재개할 수 없습니다.", + "confirm": "삭제" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 50700ac525e..76be01349b6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11190,7 +11190,9 @@ "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。", - "prepareSessionResumeFailed": "无法为恢复准备此会话。" + "prepareSessionResumeFailed": "无法为恢复准备此会话。", + "sessionDeleted": "会话已删除", + "sessionDeleteFailed": "无法删除会话" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -11322,7 +11324,17 @@ "jumpToWorktree": "跳转到工作树", "recoverableBadge": "未保存", "subagentCountSingular": "1 个子智能体", - "subagentCountPlural": "{{value0}} 个子智能体" + "subagentCountPlural": "{{value0}} 个子智能体", + "delete": "删除", + "deleteReasonNonLocalHost": "只能删除此设备上的会话。", + "deleteReasonSyntheticPath": "此会话无法在 Orca 中删除。", + "deleteReasonUnsupportedAgent": "{{value0}} 会话无法在 Orca 中删除。", + "deleteReasonSessionLive": "此会话仍在运行 — 请等待其完成后再删除。" + }, + "AiVaultSessionDeleteDialog": { + "title": "要删除此会话吗?", + "description": "“{{value0}}”将被删除。删除后,也将无法通过 {{value1}} 自身的命令行恢复。", + "confirm": "删除" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0067f122ae6..c90c7cd95ec 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -9,6 +9,7 @@ import type { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { parseHostAccessLink } from '../../../shared/remote-pairing-address' import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' +import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion' import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, @@ -1542,6 +1543,15 @@ function createAiVaultApi(): NonNullable['aiVault']> { listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. getFirstUserPrompt: () => Promise.resolve({ prompt: null }), + // Why: session deletion is local-only and has no runtime RPC; a web + // client's sessions are runtime-hosted, so report the same non-local + // rejection the UI already gates on rather than pretend to delete. + deleteSession: (args: AiVaultDeleteSessionArgs) => + Promise.resolve({ + outcome: 'rejected', + agent: args.agent, + reason: 'non-local-host' as const + }), onWindowFocused: () => noopUnsubscribe } } diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts new file mode 100644 index 00000000000..ce605022236 --- /dev/null +++ b/src/shared/ai-vault-session-deletion.ts @@ -0,0 +1,105 @@ +import type { AiVaultAgent } from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' + +// IPC payload for aiVault:deleteSession. +export type AiVaultDeleteSessionArgs = { + agent: AiVaultAgent + filePath: string + // The session's host; only a local session may be deleted. + executionHostId?: ExecutionHostId +} + +export type AiVaultDeleteSessionResult = + | { outcome: 'deleted' } + | { outcome: 'rejected'; agent: AiVaultAgent; reason: AiVaultSessionDeleteRejectionCode } + | { outcome: 'failed'; agent: AiVaultAgent; error: string } + +// Agents whose sessions Orca can remove completely: everything the session +// wrote is derivable from the one path the scanner surfaced, and none of it is +// shared with another session. +// +// The rest are excluded, recorded here because the UI deliberately won't say +// why (a provider's storage layout is Orca's problem, not the reader's): +// - antigravity, kimi: a separate registry (history.jsonl / session_index.jsonl) +// would keep a dangling entry. Antigravity's carries no conversation id, so +// which line to drop can't be determined at all. +// - codex: session_index.jsonl plus hardlink aliases between the Orca-managed +// home and ~/.codex, so a one-sided delete reappears on the next scan. +// - opencode 1.17.x: a SQLite row, not a file. +export const AI_VAULT_DELETABLE_AGENTS = [ + 'gemini', + 'copilot', + 'cursor', + 'hermes', + 'devin', + 'openclaw', + 'droid', + 'pi', + 'omp', + 'claude', + 'rovo', + 'grok' +] as const satisfies readonly AiVaultAgent[] + +export type AiVaultDeletableAgent = (typeof AI_VAULT_DELETABLE_AGENTS)[number] + +export function isAiVaultDeletableAgent(agent: AiVaultAgent): agent is AiVaultDeletableAgent { + return (AI_VAULT_DELETABLE_AGENTS as readonly AiVaultAgent[]).includes(agent) +} + +// A '#' marks an OpenCode 1.17.x SQLite row's synthetic `#` +// identity — no real file to open or delete. '#' never appears in a genuine +// transcript path. +export function isAiVaultSyntheticSessionPath(filePath: string): boolean { + return filePath.includes('#') +} + +export type AiVaultSessionDeleteRejectionCode = + | 'invalid-path' + | 'unsupported-agent' + | 'non-local-host' + | 'synthetic-path' + | 'path-outside-known-roots' + // The scanner would never have surfaced this path as a session row, so it is + // not a session to delete (wrong extension, or a pruned subagent transcript). + | 'undiscoverable-path' + // A directory-shaped agent's file sits directly in the sessions root, so it + // names no session dir of its own — removing it would trash every session. + | 'no-session-directory' + // fs-side guard: lstat disagrees with the removal's declared kind (a symlink, + // or a file where the plan expects a directory). + | 'unexpected-target-kind' + +// One path the executor removes. A `kind` mismatch on disk is a rejection, +// never a coerced delete. `roots` are what the path's realpath must still +// resolve inside — a symlinked parent escapes the validator's textual check. +export type AiVaultSessionDeleteRemoval = { + path: string + kind: 'file' | 'directory' + roots: readonly string[] +} + +// CALLER CONTRACT: `allowed: true` is a path-only judgement — the validator +// never touches disk, so it can't tell a session file from a same-named +// directory or a symlink planted inside a root. Before removing anything the +// caller MUST re-check each removal's `kind` and realpath it against `roots`. +export type AiVaultSessionDeleteAllowedResult = { + allowed: true + agent: AiVaultDeletableAgent + // The transcript path (also the last `removals` entry); cache invalidation + // keys off it. + resolvedPath: string + // Companions first, transcript last: a failed companion leaves the row on + // screen to retry from, where transcript-first would strand it on disk. + removals: readonly AiVaultSessionDeleteRemoval[] +} + +export type AiVaultSessionDeleteRejectedResult = { + allowed: false + agent: AiVaultAgent + reason: AiVaultSessionDeleteRejectionCode +} + +export type AiVaultSessionDeleteValidationResult = + | AiVaultSessionDeleteAllowedResult + | AiVaultSessionDeleteRejectedResult diff --git a/tests/e2e/ai-vault-session-delete.spec.ts b/tests/e2e/ai-vault-session-delete.spec.ts new file mode 100644 index 00000000000..db2b387e957 --- /dev/null +++ b/tests/e2e/ai-vault-session-delete.spec.ts @@ -0,0 +1,127 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import type { AiVaultSession } from '../../src/shared/ai-vault-types' +import type { AiVaultDeleteSessionResult } from '../../src/shared/ai-vault-session-deletion' + +// Exercises the real on-disk delete against an isolated, disposable HOME (the +// E2E harness redirects os.homedir() there). The unit tests mock lstat/realpath/ +// trashItem; this proves the whole IPC path actually removes files — and, for a +// directory-shaped agent, that the companion tree goes while the rewind buffer +// stays. Runs on Linux CI. + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +function isolatedHome(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(({ app }) => app.getPath('home')) +} + +async function findSession( + page: Page, + agent: string, + title: string +): Promise { + const sessions = (await page.evaluate(async () => { + const result = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + return result.sessions + })) as AiVaultSession[] + return sessions.find((session) => session.agent === agent && session.title === title) +} + +function deleteSession(page: Page, session: AiVaultSession): Promise { + return page.evaluate( + async (target) => + window.api.aiVault.deleteSession({ + agent: target.agent, + filePath: target.filePath, + executionHostId: target.executionHostId + }), + session + ) +} + +test.describe('AI Vault session delete', () => { + test('trashes a single-file session and removes it from the list', async ({ + electronApp, + orcaPage + }) => { + const homeDir = await isolatedHome(electronApp) + const title = `E2E gemini ${Date.now()}` + const dir = path.join(homeDir, '.gemini', 'tmp', 'proj', 'chats') + mkdirSync(dir, { recursive: true }) + const filePath = path.join(dir, 'session-e2e.json') + writeFileSync( + filePath, + JSON.stringify({ + sessionId: 'gemini-e2e', + startTime: '2026-07-20T10:00:00.000Z', + lastUpdated: '2026-07-20T10:05:00.000Z', + messages: [{ type: 'user', content: title, timestamp: '2026-07-20T10:00:00.000Z' }] + }) + ) + + const session = await findSession(orcaPage, 'gemini', title) + expect(session, 'seeded gemini session should be listed').toBeTruthy() + + const result = await deleteSession(orcaPage, session as AiVaultSession) + + expect(result.outcome).toBe('deleted') + expect(existsSync(filePath), 'transcript should be gone from disk').toBe(false) + expect(await findSession(orcaPage, 'gemini', title)).toBeFalsy() + }) + + test('trashes a claude directory session and its companions but keeps file-history', async ({ + electronApp, + orcaPage + }) => { + const homeDir = await isolatedHome(electronApp) + const title = `E2E claude ${Date.now()}` + const uuid = '11111111-2222-3333-4444-555555555555' + const projectDir = path.join(homeDir, '.claude', 'projects', '-e2e-project') + mkdirSync(projectDir, { recursive: true }) + const transcript = path.join(projectDir, `${uuid}.jsonl`) + writeFileSync( + transcript, + jsonl([ + { + type: 'user', + sessionId: uuid, + timestamp: '2026-07-20T10:00:00.000Z', + cwd: projectDir, + message: { role: 'user', content: title } + } + ]) + ) + const subagentsDir = path.join(projectDir, uuid, 'subagents') + mkdirSync(subagentsDir, { recursive: true }) + writeFileSync(path.join(subagentsDir, 'agent-a1.jsonl'), jsonl([{ type: 'user' }])) + const sessionEnvDir = path.join(homeDir, '.claude', 'session-env', uuid) + mkdirSync(sessionEnvDir, { recursive: true }) + writeFileSync(path.join(sessionEnvDir, 'hook.sh'), 'export X=1\n') + const fileHistoryDir = path.join(homeDir, '.claude', 'file-history', uuid) + mkdirSync(fileHistoryDir, { recursive: true }) + const rewindFile = path.join(fileHistoryDir, 'deadbeef@v1') + writeFileSync(rewindFile, 'earlier version of a user file\n') + + const session = await findSession(orcaPage, 'claude', title) + expect(session, 'seeded claude session should be listed').toBeTruthy() + + const result = await deleteSession(orcaPage, session as AiVaultSession) + + expect(result.outcome).toBe('deleted') + expect(existsSync(transcript), 'transcript gone').toBe(false) + expect( + existsSync(path.join(projectDir, uuid)), + 'session directory gone (no empty shell left behind)' + ).toBe(false) + expect(existsSync(sessionEnvDir), 'session-env companion gone').toBe(false) + expect(existsSync(rewindFile), 'file-history rewind buffer preserved').toBe(true) + expect(await findSession(orcaPage, 'claude', title)).toBeFalsy() + }) +}) From 6382b8a053d0f1f5f4fdcd64f95fd4e5b369abd1 Mon Sep 17 00:00:00 2001 From: seoo2001 <96180608+seoo2001@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:50:38 -0700 Subject: [PATCH 86/88] fix(file-explorer): report the saved filename in the download toast (#12959) * fix(file-explorer): report the saved filename in the download toast The success toast named the remote node, so renaming a file in the native save dialog left the label disagreeing with its own Open action, which opens the real destination. Folder downloads had the same gap whenever sanitizeLocalDownloadFilename rewrote the remote basename. * fix(file-explorer): respect local download path semantics --------- Co-authored-by: Jinwoo-H --- .../right-sidebar/FileExplorer.test.tsx | 40 +++++++++++++++++-- .../right-sidebar/FileExplorerRow.tsx | 17 +++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 1688b0721f3..37fce9ae624 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -293,6 +293,14 @@ function makeToolbar(overrides: Partial[0 }) } +function setDownloadPlatform(platform: NodeJS.Platform): void { + ;( + window as unknown as { + api: { platform: { get: () => { platform: NodeJS.Platform } } } + } + ).api.platform = { get: () => ({ platform }) } +} + beforeEach(() => { toastErrorMock.mockReset() toastSuccessMock.mockReset() @@ -677,7 +685,10 @@ describe('FileExplorerRow collapse folder action', () => { it('calls the preload download API and shows success only when not canceled', async () => { const downloadFile = vi .fn() - .mockResolvedValueOnce({ canceled: false, destinationPath: '/downloads/index.ts' }) + .mockResolvedValueOnce({ + canceled: false, + destinationPath: '/downloads/renamed\\entry.ts' + }) .mockResolvedValueOnce({ canceled: true }) const openPath = vi.fn().mockResolvedValue(undefined) ;( @@ -690,6 +701,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFile }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, 'ssh-1') await downloadRemoteFile(fileNode, 'ssh-1') @@ -699,7 +711,7 @@ describe('FileExplorerRow collapse folder action', () => { connectionId: 'ssh-1' }) expect(toastSuccessMock).toHaveBeenCalledTimes(1) - expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", { + expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'renamed\\entry.ts'", { action: { label: 'Open', onClick: expect.any(Function) @@ -709,10 +721,30 @@ describe('FileExplorerRow collapse folder action', () => { | { onClick: () => void } | undefined action?.onClick() - expect(openPath).toHaveBeenCalledWith('/downloads/index.ts') + expect(openPath).toHaveBeenCalledWith('/downloads/renamed\\entry.ts') expect(toastErrorMock).not.toHaveBeenCalled() }) + it('reports the saved folder name from a Windows destination path', async () => { + const downloadFolder = vi.fn().mockResolvedValue({ + canceled: false, + destinationPath: 'C:\\Users\\dev\\Downloads\\src-copy' + }) + ;( + globalThis as unknown as { + window: { api: { fs: { downloadFolder: typeof downloadFolder } } } + } + ).window = { api: { fs: { downloadFolder } } } + setDownloadPlatform('win32') + + await downloadRemoteFile(directoryNode, 'ssh-1') + + expect(toastSuccessMock).toHaveBeenCalledWith( + "Downloaded folder 'src-copy'", + expect.objectContaining({ action: expect.anything() }) + ) + }) + it('calls the preload folder download API for SSH directory rows', async () => { const downloadFolder = vi.fn().mockResolvedValue({ canceled: false, @@ -729,6 +761,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { fs: { downloadFolder }, shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(directoryNode, 'ssh-1') @@ -765,6 +798,7 @@ describe('FileExplorerRow collapse folder action', () => { } } ).window = { api: { shell: { openPath } } } + setDownloadPlatform('linux') await downloadRemoteFile(fileNode, runtimeContext) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 1b621454a95..6167f08d5c6 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -342,6 +342,14 @@ export function shouldShowCopyFileAction( ) } +function getLocalDownloadName(destinationPath: string, platform: NodeJS.Platform): string { + const lastSeparatorIndex = + platform === 'win32' + ? Math.max(destinationPath.lastIndexOf('/'), destinationPath.lastIndexOf('\\')) + : destinationPath.lastIndexOf('/') + return destinationPath.slice(lastSeparatorIndex + 1) +} + export async function downloadRemoteFile( node: TreeNode, connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs @@ -363,17 +371,22 @@ export async function downloadRemoteFile( if (result.canceled) { return } + // Why: POSIX permits backslashes in saved names; only Windows treats them as separators. + const savedName = getLocalDownloadName( + result.destinationPath, + window.api.platform.get().platform + ) toast.success( node.isDirectory ? translate( 'auto.components.right.sidebar.FileExplorerRow.a4029c996b', "Downloaded folder '{{value0}}'", - { value0: node.name } + { value0: savedName } ) : translate( 'auto.components.right.sidebar.FileExplorerRow.bce4d4e44f', "Downloaded '{{value0}}'", - { value0: node.name } + { value0: savedName } ), { action: { From 2e199cb2dd63ed81704559dd5dd0d89d541735b9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:18:49 +0900 Subject: [PATCH 87/88] fix(ai-vault): tolerate unknown session agents (#12999) * fix: tolerate unknown AI Vault agents * fix: tolerate unknown AI Vault scan issues * test(ai-vault): cover malformed unknown-agent rows --------- Co-authored-by: Jinwoo-H --- .../session-list-result-validation.test.ts | 90 +++++++++++++++++-- .../session-list-result-validation.ts | 59 +++++++++--- 2 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index 9cdc22a8fb4..af1a5e34ec2 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -19,6 +19,78 @@ describe('parseAiVaultListResult', () => { ) }) + it('keeps known-agent sessions while silently dropping unknown-agent rows', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession(), { ...validSession('session-new'), agent: 'future-agent' }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]?.agent).toBe('codex') + expect(parsed.sessions[0]?.sessionId).toBe('session-1') + expect(parsed.issues).toEqual([]) + }) + + it('accepts an all-unknown-agent response as an empty supported session list', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('future-1'), agent: 'future-agent' }, + { ...validSession('future-2'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toEqual([]) + }) + + it('keeps known scan issues while silently dropping unknown-agent issues', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), validScanIssue('future-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([validScanIssue()]) + }) + + it('reports malformed scan issues without counting unknown-agent issues as invalid', () => { + const parsed = parseAiVaultListResult({ + sessions: [], + issues: [validScanIssue(), { agent: 'future-agent' }, validScanIssue('newer-agent')], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.issues).toEqual([ + validScanIssue(), + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ]) + }) + + it('does not throw when malformed rows are mixed with well-formed unknown sessions', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('malformed-future'), agent: 'future-agent', messageCount: 'invalid' }, + { ...validSession('valid-future'), agent: 'newer-agent' } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toEqual([]) + expect(parsed.issues).toContainEqual( + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ) + }) + it('preserves the optional session fields the panel renders', () => { const parsed = parseAiVaultListResult({ sessions: [ @@ -55,18 +127,18 @@ describe('parseAiVaultListResult', () => { }) }) -function validSession(): Record { +function validSession(sessionId = 'session-1'): Record { return { - id: 'local:codex:session-1:/tmp/session-1.jsonl', + id: `local:codex:${sessionId}:/tmp/${sessionId}.jsonl`, executionHostId: 'local', executionHostPlatform: 'linux', agent: 'codex', - sessionId: 'session-1', + sessionId, title: 'Session one', cwd: '/repo', branch: null, model: null, - filePath: '/tmp/session-1.jsonl', + filePath: `/tmp/${sessionId}.jsonl`, codexHome: null, createdAt: null, updatedAt: null, @@ -76,7 +148,15 @@ function validSession(): Record { previewMessages: [], queuedMessageCount: 0, subagentTranscriptCount: 0, - resumeCommand: 'codex resume session-1', + resumeCommand: `codex resume ${sessionId}`, subagent: null } } + +function validScanIssue(agent = 'codex'): Record { + return { + agent, + path: '/tmp/sessions', + message: 'permission denied' + } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 6d69a06af45..abcdd1612b3 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,7 +1,19 @@ import { z } from 'zod' -import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types' +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultListResult, + type AiVaultScanIssue, + type AiVaultSession +} from '../../shared/ai-vault-types' import { normalizeExecutionHostId } from '../../shared/execution-host' +const aiVaultAgentSet = new Set(AI_VAULT_AGENTS) + +function isAiVaultAgent(agent: string): agent is AiVaultAgent { + return aiVaultAgentSet.has(agent) +} + const nodePlatformSchema = z.enum([ 'aix', 'android', @@ -35,7 +47,7 @@ const aiVaultSessionSchema = z.object({ id: z.string(), executionHostId: executionHostIdSchema, executionHostPlatform: nodePlatformSchema.nullable().optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), sessionId: z.string(), title: z.string(), cwd: z.string().nullable(), @@ -67,7 +79,7 @@ const aiVaultSessionSchema = z.object({ const aiVaultScanIssueSchema = z.object({ executionHostId: executionHostIdSchema.optional(), - agent: z.enum(AI_VAULT_AGENTS), + agent: z.string().min(1), kind: z.enum(['host', 'scope', 'notice']).optional(), path: z.string(), message: z.string() @@ -84,19 +96,42 @@ export function parseAiVaultListResult(value: unknown): AiVaultListResult { if (!envelope.success) { throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape') } - const sessions = envelope.data.sessions.flatMap((session) => { + const sessions: AiVaultSession[] = [] + let malformedSessionCount = 0 + let wellFormedSessionCount = 0 + for (const session of envelope.data.sessions) { const parsed = aiVaultSessionSchema.safeParse(session) - return parsed.success ? [parsed.data] : [] - }) - if (envelope.data.sessions.length > 0 && sessions.length === 0) { + if (!parsed.success) { + malformedSessionCount += 1 + continue + } + wellFormedSessionCount += 1 + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + sessions.push({ ...parsed.data, agent: parsed.data.agent }) + } + if ( + envelope.data.sessions.length > 0 && + malformedSessionCount > 0 && + wellFormedSessionCount === 0 + ) { throw new Error('all supplied Agent Session History sessions were invalid') } - const issues = envelope.data.issues.flatMap((issue) => { + const issues: AiVaultScanIssue[] = [] + let malformedIssueCount = 0 + for (const issue of envelope.data.issues) { const parsed = aiVaultScanIssueSchema.safeParse(issue) - return parsed.success ? [parsed.data] : [] - }) - const invalidCount = - envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length) + if (!parsed.success) { + malformedIssueCount += 1 + continue + } + if (!isAiVaultAgent(parsed.data.agent)) { + continue + } + issues.push({ ...parsed.data, agent: parsed.data.agent }) + } + const invalidCount = malformedSessionCount + malformedIssueCount if (invalidCount > 0) { issues.push({ agent: 'codex', From 8c3e9535c75b379ca9ac68b5d35ddfca196ea3df Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:29:00 -0700 Subject: [PATCH 88/88] fix(terminal): remove permanent link tooltip gap (#13075) --- .../terminal-container-geometry.test.ts | 18 ++++++++++++ src/renderer/src/assets/terminal.css | 9 ++---- .../issue-12656-terminal-link-tooltip.spec.ts | 28 ++++++++----------- 3 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/assets/terminal-container-geometry.test.ts diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts new file mode 100644 index 00000000000..db079c9095f --- /dev/null +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), 'utf8') + +describe('terminal container geometry', () => { + it('keeps the hidden link tooltip out of the fitted terminal height', () => { + expect(terminalCss).toMatch( + /\.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--pane-padding-y, 4px\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane\[data-has-title\] \.xterm-container\s*{[^}]*height:\s*calc\(100% - var\(--orca-pane-title-height\)\);/s + ) + expect(terminalCss).toMatch( + /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s + ) + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 3d9a5df2169..ee5e0af01f5 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -6,8 +6,7 @@ position: relative; } -/* Why: .pane-manager-root matches no live element, so a var declared there - never resolves and every calc() using it is dropped. Keep it on :root. */ +/* Shared tooltip geometry for every terminal link provider. */ :root { --orca-terminal-link-tooltip-height: 25px; } @@ -506,7 +505,7 @@ box-sizing: border-box; position: relative; width: calc(100% - var(--pane-padding-x, 4px)); - height: calc(100% - var(--pane-padding-y, 4px) - var(--orca-terminal-link-tooltip-height, 25px)); + height: calc(100% - var(--pane-padding-y, 4px)); margin-top: var(--pane-padding-y, 4px); margin-left: var(--pane-padding-x, 4px); } @@ -517,9 +516,7 @@ same amount to prevent overflow/clipping. */ .pane[data-has-title] .xterm-container { margin-top: var(--orca-pane-title-height); - height: calc( - 100% - var(--orca-pane-title-height) - var(--orca-terminal-link-tooltip-height, 25px) - ); /* match margin-top and the tooltip reserve */ + height: calc(100% - var(--orca-pane-title-height)); /* match margin-top */ } /* Ghostty-style URL hover: glued to the pane's true bottom-left corner. */ diff --git a/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts b/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts index 56c27dc1925..74c7971b8d0 100644 --- a/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts +++ b/tests/e2e/issue-12656-terminal-link-tooltip.spec.ts @@ -25,8 +25,8 @@ type TooltipState = { paneBottom: number terminalBottom: number tooltipTop: number + tooltipBottom: number tooltipHeight: number - reserveHeight: number } async function locateUrl(page: Page, url: string): Promise { @@ -98,7 +98,6 @@ async function readTooltipState(page: Page, tabId: string): Promise { - test('clears hover state on window blur and reserves the tooltip strip', async ({ + test('clears hover state without permanently shrinking the terminal', async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) @@ -150,6 +149,8 @@ test.describe('Issue #12656 terminal link tooltip', () => { if (!probe) { throw new Error('URL probe disappeared before hover') } + const idle = await readTooltipState(orcaPage, probe.tabId) + expect(Math.abs(idle.paneBottom - idle.terminalBottom)).toBeLessThanOrEqual(1) await expect .poll(async () => { await moveToLink(orcaPage, probe) @@ -160,26 +161,19 @@ test.describe('Issue #12656 terminal link tooltip', () => { const hovered = await readTooltipState(orcaPage, probe.tabId) expect(hovered.text).toContain(url) expect(hovered.tooltipHeight).toBeGreaterThan(0) - expect(hovered.tooltipTop).toBeGreaterThanOrEqual(hovered.terminalBottom - 1) - // Why: bound the gap on both sides. A lower bound alone also passes when the - // reserve var fails to resolve and .xterm-container collapses to height:auto, - // which leaves a huge gap and an undersized terminal. - expect(hovered.paneBottom - hovered.terminalBottom).toBeGreaterThanOrEqual( - hovered.reserveHeight - 1 - ) - expect(hovered.paneBottom - hovered.terminalBottom).toBeLessThanOrEqual( - hovered.reserveHeight + 1 - ) + expect(Math.abs(hovered.paneBottom - hovered.terminalBottom)).toBeLessThanOrEqual(1) + expect(Math.abs(hovered.paneBottom - hovered.tooltipBottom)).toBeLessThanOrEqual(1) + expect(hovered.tooltipTop).toBeLessThan(hovered.terminalBottom) await captureProof(orcaPage, testInfo, 'issue-12656-fixed-hover.png') await orcaPage.evaluate(() => window.dispatchEvent(new Event('blur'))) await expect .poll(() => readTooltipState(orcaPage, probe.tabId)) .toMatchObject({ display: 'none', currentLinkText: null, cursor: 'text' }) + const cleared = await readTooltipState(orcaPage, probe.tabId) + expect(Math.abs(cleared.paneBottom - cleared.terminalBottom)).toBeLessThanOrEqual(1) await captureProof(orcaPage, testInfo, 'issue-12656-fixed-after-blur.png') - // Keep the output assertion adjacent to the visual state checks so the - // reserved strip cannot hide the final terminal line without detection. await expect.poll(() => getTerminalContent(orcaPage)).toContain(url) }) })