diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ec1cd450561..054f1748ea6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3896,6 +3896,7 @@ "layer": "runtime-rpc-contract", "surfaces": [ "mobile terminal", + "native-chat terminal overlay", "runtime RPC", "binary multiplex", "JSON subscribe fallback", @@ -3917,21 +3918,25 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", + "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", - "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", + "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "testFiles": [ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/rpc/terminal-output-batching.test.ts", - "src/main/runtime/rpc/terminal-multiplex.test.ts" + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "assertionRefs": [ { @@ -3956,6 +3961,21 @@ "oversized live output frames are bounded for subscribed binary streams", "multibyte live output flushes when encoded bytes reach the batch budget" ] + }, + { + "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "an active terminal stream pauses while native chat covers its WebView", + "the stream resumes only for the ready active terminal after chat closes", + "non-terminal and already-settled stream states perform no lifecycle work" + ] + }, + { + "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "a late WebView-ready transition converts a cold-start lease-only stream back to output", + "ordinary WebView readiness does not rerender the mobile session route" + ] } ], "evidenceRuns": [ @@ -3967,6 +3987,15 @@ "result": "passed", "durationSeconds": 3.8, "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree." } ], "runtimeBudget": { @@ -3991,7 +4020,7 @@ "Record byte counts and order/cursor artifacts on failure." ], "knownGaps": [ - "Current command covers binary runtime/mobile stream contracts only.", + "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.", "Legacy JSON subscribe parity is undecided." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d6127bbe29b..417924fc997 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.7m + + downloads: 5.8m @@ -15,7 +15,7 @@ downloads downloads - 5.7m - 5.7m + 5.8m + 5.8m diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d4d1b35896f..0251da70490 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -26,10 +26,8 @@ import { Bot, ChevronDown, ChevronLeft, - ChevronRight, ChevronsRight, Copy, - Eraser, Folder, File, FileText, @@ -174,6 +172,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' @@ -198,6 +197,15 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' +import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' +import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' +import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' +import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' +import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' +import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' +import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab, focusMobileTerminal @@ -249,8 +257,6 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -type TerminalLiveAccessoryInput = ReturnType - const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function MarkdownReader({ @@ -1167,11 +1173,48 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) + const showNativeChatSendError = useCallback( + (message: string) => showToast(message, 1600), + [showToast] + ) + const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) + const { + ready: nativeChatInputLeaseReady, + readyRef: nativeChatInputLeaseReadyRef, + lockReason: nativeChatInputLockReason, + markReady: markNativeChatInputLeaseReady, + clear: clearNativeChatInputLease + } = useMobileNativeChatInputLease({ + activeHandle, + connected: connState === 'connected' + }) + const nativeChatController = useMobileNativeChatController({ + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError: showNativeChatSendError + }) + const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, enabled: canSend, onTranscript: (text) => { + // Why: dictation belongs to the visible composer. Native chat consumes it + // locally; terminal mode retains the live-input routing and flush contract. + if (showNativeChatRef.current) { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + showToast('Dictation inserted') + return + } // Live mode inserts the transcript straight into its originating PTY as // text (no Return — the user sends it themselves), matching live keystroke // semantics; buffered mode keeps appending to the command field. @@ -1290,22 +1333,27 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const unsubscribeTerminal = useCallback((handle: string) => { - terminalUnsubsRef.current.get(handle)?.() - terminalUnsubsRef.current.delete(handle) - subscribingHandlesRef.current.delete(handle) - terminalDiagnosticsRef.current.terminalUnsubscribed(handle) - subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. - layoutSeqRef.current.delete(handle) - }, []) + const unsubscribeTerminal = useCallback( + (handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) + clearNativeChatInputLease(handle) + }, + [clearNativeChatInputLease] + ) + const unsubscribeTerminalRef = useRef(unsubscribeTerminal) + unsubscribeTerminalRef.current = unsubscribeTerminal const clearTerminalCache = useCallback(() => { - for (const unsub of terminalUnsubsRef.current.values()) { - unsub() - } + terminalUnsubsRef.current.forEach((unsub) => unsub()) + clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() @@ -1318,7 +1366,7 @@ export default function SessionScreen() { for (const term of terminalRefs.current.values()) { term.clear() } - }, []) + }, [clearNativeChatInputLease]) // Why: measures the phone viewport once from the first available TerminalWebView. // The viewport dims are passed with every subscribe call so the server can @@ -1357,13 +1405,22 @@ export default function SessionScreen() { logSkippedGate('subscribe-in-flight') return } - if (!getTerminalRef(handle)) { - logSkippedGate('no-webview-ref') - return - } - if (!webReadyHandlesRef.current.has(handle)) { - logSkippedGate('webview-not-ready') - return + const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + // Why: a native-chat-covered terminal subscribes as the input-floor lease + // without a mounted xterm webview, so only gate on the webview when NOT covered. + if (!covered) { + if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') + return + } + if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') + return + } } subscribingHandlesRef.current.add(handle) @@ -1375,13 +1432,13 @@ export default function SessionScreen() { // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit // race and the measure→resize→resubscribe pipeline. - const unsub = client.subscribe( - 'terminal.subscribe', + const unsub = subscribeMobileTerminalSafely( + client, { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, viewport: viewportRef.current ?? undefined, - capabilities: { terminalBinaryStream: 1 } + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { @@ -1389,6 +1446,25 @@ export default function SessionScreen() { } const data = result as Record diagnostics.firstStreamEvent(handle, seq, data.type) + if (data.type === 'end' || data.type === 'error') { + unsubscribeTerminalRef.current(handle) + return + } + if (data.type === 'subscribed') { + markNativeChatInputLeaseReady(handle) + return + } + // Why: retain the subscription as the mobile input-floor lease, but + // do not mutate covered xterm state; return-to-terminal resubscribes. + if ( + nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + ) { + return + } // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1417,9 +1493,6 @@ export default function SessionScreen() { } else if (eventSeq != null && data.type === 'scrollback') { layoutSeqRef.current.set(handle, eventSeq) } - if (data.type === 'subscribed') { - return - } if (data.type === 'scrollback') { diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { @@ -1572,7 +1645,8 @@ export default function SessionScreen() { } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } - } + }, + () => unsubscribeTerminalRef.current(handle) ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1582,9 +1656,21 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) + const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle, + activeTabType: activeSessionTab?.type ?? null, + subscriptionsRef: terminalUnsubsRef, + subscribingRef: subscribingHandlesRef, + webReadyRef: webReadyHandlesRef, + initializedRef: initializedHandlesRef, + subscribe: subscribeToTerminal, + unsubscribe: unsubscribeTerminal + }) + // Why: toggles between phone and desktop mode via server RPC. The server // handles the actual resize and emits a 'resized' event on the existing // subscription stream — no client-side state tracking needed. @@ -3064,6 +3150,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + notifyTerminalWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -3105,7 +3192,7 @@ export default function SessionScreen() { })() } }, - [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3156,7 +3243,7 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { + async function handleAccessoryKey(input: ReturnType) { if (!client || !activeHandle || !canSend) { return } @@ -3643,7 +3730,7 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (input: TerminalLiveAccessoryInput) => { + (input: ReturnType) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { @@ -3815,19 +3902,14 @@ export default function SessionScreen() { showToast }) - const flushPendingLiveInputBeforeAttachmentSend = useCallback( - async (targetHandle: string): Promise => { - const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) - // Why: image picking/upload and IME flushing can outlive the original tab. - return ( - flushedPendingInput && - connStateRef.current === 'connected' && - targetHandle === activeHandleRef.current && - activeSessionTabTypeRef.current === 'terminal' - ) - }, - [flushPendingLiveInputBeforeExternalSend] - ) + const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast + }) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -4887,6 +4969,18 @@ export default function SessionScreen() { onOpenUrl={handleTerminalOpenUrl} /> ))} + void attachImage('library')} + isAttaching={isAttaching} + onMicPress={handleDictationToggle} + micActive={dictation.isRecording} + dictationMode={dictationMode} + onMicPressIn={handleDictationPressIn} + onMicPressOut={handleDictationPressOut} + inputLockReason={nativeChatInputLockReason} + keyboardInset={keyboardLift} + /> {toastMessage && ( {toastMessage} @@ -4896,8 +4990,9 @@ export default function SessionScreen() { )} {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. */} - {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && ( + trigger a server-side PTY viewport change. The dock hides in native + chat because that view supplies its own composer. */} + {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( { - const target = actionTarget - setActionTarget(null) - if (target) { - void toggleDisplayMode(target.handle) - } - } - } - ] - : []), - { - label: 'Rename', - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - setRenameTarget(target) - } - } - }, - { - label: 'Clear Terminal', - icon: Eraser, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleClearTerminal(target) - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleCloseTerminal(target) - } - } - } - ]} + actions={getMobileTerminalActionSheetActions({ + target: actionTarget, + tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), + chatTabIds, + nativeChatTranscriptIsLocalReadable, + onDismiss: () => setActionTarget(null), + onToggleChat: toggleTabChatView, + isPhoneMode, + onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), + onRename: setRenameTarget, + onClear: (target) => void handleClearTerminal(target), + onClose: (target) => void handleCloseTerminal(target) + })} onClose={() => setActionTarget(null)} /> setFileActionTarget(null)} /> - { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.back') - } - } - } - ] - : []), - ...(browserActionTarget?.canGoForward - ? [ - { - label: 'Forward', - icon: ChevronRight, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.forward') - } - } - } - ] - : []), - { - label: 'Reload', - icon: RefreshCw, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.reload') - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } - ]} + setBrowserActionTarget(null)} + onNavigate={handleBrowserNavigationCommand} + onCloseTab={handleCloseSessionTab} /> 1 so agent prose reads larger than the compact base. */ + textScale?: number + /** When provided, detected file-path tokens render as tappable and invoke this + * with the worktree-relative path. Omitted on screens with no file viewer, where + * paths render as plain text (no behavior change). */ + onOpenFile?: (relativePath: string) => void } const MAX_TABLE_ROWS = 40 @@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void { } } -function renderInline(text: string): ReactNode[] { +// Render a plain (non-token) text run, splitting out tappable file paths when +// onOpenFile is provided. Without it, paths stay plain text. +function renderTextRun( + text: string, + keyPrefix: string, + onOpenFile?: (relativePath: string) => void +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => { + if (segment.type === 'file') { + return ( + onOpenFile(segment.path)} + > + {segment.value} + + ) + } + return {segment.value} + }) +} + +function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g @@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] { while ((match = pattern.exec(text))) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)) + parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) } const token = match[0] const key = `${match.index}:${token}` @@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] { ) } else if (token.startsWith('`')) { - parts.push( - - {token.slice(1, -1)} - - ) + const code = token.slice(1, -1) + if (onOpenFile && isFilePathCodeSpan(code)) { + parts.push( + onOpenFile(normalizeFilePath(code.trim()))} + > + {code} + + ) + } else { + parts.push( + + {code} + + ) + } } else if (token.startsWith('~~')) { parts.push( @@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] { } if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)) + parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) } return parts } -function MobileMarkdownInner({ content, fallback = '' }: Props) { +function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) { const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const proseScale = scaled(13) + const listScale = scaled(14) if (!text) { return fallback ? {fallback} : null } @@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { key={index} style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]} > - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } if (block.type === 'quote') { return ( - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } @@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((header, cellIndex) => ( - {renderInline(header)} + {renderInline(header, onOpenFile)} ))} @@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((_, cellIndex) => ( - {renderInline(row[cellIndex] ?? '')} + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { ? '[x]' : '[ ]'} - {renderInline(item.text)} + + {renderInline(item.text, onOpenFile)} + ))} @@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} - {renderInline(line)} + {renderInline(line, onOpenFile)} ))} @@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { } export const MobileMarkdown = memo(MobileMarkdownInner) - -const styles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - paragraph: { - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - heading: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.textPrimary - }, - headingLarge: { - fontSize: 15, - lineHeight: 21 - }, - bold: { - fontWeight: '700', - color: colors.textPrimary - }, - italic: { - fontStyle: 'italic' - }, - strike: { - textDecorationLine: 'line-through' - }, - link: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - inlineCode: { - fontFamily: typography.monoFamily, - fontSize: 12, - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderRadius: radii.row, - paddingHorizontal: 4 - }, - quote: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm - }, - quoteText: { - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary - }, - codeBlock: { - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - padding: spacing.sm - }, - codeLanguage: { - fontSize: 10, - color: colors.textMuted, - marginBottom: spacing.xs, - textTransform: 'uppercase' - }, - codeText: { - fontFamily: typography.monoFamily, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - imageFrame: { - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgRaised, - overflow: 'hidden', - padding: spacing.sm - }, - imageCaption: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 11, - color: colors.textSecondary - }, - table: { - borderTopWidth: 1, - borderLeftWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - overflow: 'hidden', - backgroundColor: colors.bgPanel - }, - tableRow: { - flexDirection: 'row' - }, - tableCell: { - minWidth: 112, - maxWidth: 220, - borderRightWidth: 1, - borderBottomWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - tableHeader: { - fontWeight: '700', - backgroundColor: colors.bgRaised - }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, - list: { - gap: spacing.xs - }, - listItem: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm - }, - listMarker: { - width: 22, - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary, - fontFamily: typography.monoFamily - }, - listText: { - flex: 1, - minWidth: 0, - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - rule: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle - } -}) diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts new file mode 100644 index 00000000000..f75da01efdc --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' + +describe('detectFilePathSegments', () => { + it('returns a single text segment when there is no path', () => { + expect(detectFilePathSegments('just some prose here')).toEqual([ + { type: 'text', value: 'just some prose here' } + ]) + }) + + it('detects a relative source path with surrounding prose', () => { + const segments = detectFilePathSegments('Edit src/app/Main.tsx now') + expect(segments).toEqual([ + { type: 'text', value: 'Edit ' }, + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }, + { type: 'text', value: ' now' } + ]) + }) + + it('strips a leading ./ in the path but keeps the displayed value', () => { + const segments = detectFilePathSegments('see ./lib/x.ts') + expect(segments).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: './lib/x.ts', path: 'lib/x.ts' } + ]) + }) + + it('keeps ../ parent-relative paths intact', () => { + const segments = detectFilePathSegments('../shared/util.ts') + expect(segments).toEqual([ + { type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' } + ]) + }) + + it('detects multiple paths in one run', () => { + const segments = detectFilePathSegments('a/b.ts and c/d/e.json') + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'a/b.ts', path: 'a/b.ts' }, + { type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' } + ]) + }) + + it('detects Windows relative, drive, and UNC paths', () => { + const segments = detectFilePathSegments( + String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md` + ) + + expect(segments.filter((segment) => segment.type === 'file')).toEqual([ + { type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` }, + { + type: 'file', + value: String.raw`C:\repo\config.json`, + path: String.raw`C:\repo\config.json` + }, + { + type: 'file', + value: String.raw`\\server\share\docs\readme.md`, + path: String.raw`\\server\share\docs\readme.md` + } + ]) + }) + + it('does not match bare filenames without a slash', () => { + expect(detectFilePathSegments('open Main.tsx please')).toEqual([ + { type: 'text', value: 'open Main.tsx please' } + ]) + }) + + it('does not match URLs', () => { + expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ + { type: 'text', value: 'https://example.com/path/file.ts' } + ]) + }) + + it('does not match version numbers', () => { + expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([ + { type: 'text', value: 'upgraded to 1.2.3 today' } + ]) + }) + + it('does not match unknown extensions', () => { + expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([ + { type: 'text', value: 'path/to/thing.whatever' } + ]) + }) + + it('detects scoped-package file paths with a segment-leading @', () => { + expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([ + { type: 'text', value: 'open ' }, + { + type: 'file', + value: '@types/react/index.d.ts', + path: '@types/react/index.d.ts' + }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file') + ).toEqual([ + { + type: 'file', + value: 'node_modules/@scope/pkg/file.ts', + path: 'node_modules/@scope/pkg/file.ts' + } + ]) + }) + + it('does not match emails or git URLs with a mid-token @', () => { + expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([ + { type: 'text', value: 'clone git@github.com:user/repo.git' } + ]) + expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([ + { type: 'text', value: 'open user@host.com/path/file.txt' } + ]) + }) + + it('returns a single text segment when the run has no dot', () => { + const text = 'a/'.repeat(8192) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('skips detection for runs over the length cap even with dots', () => { + // 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that + // bounds CANDIDATE_PATTERN's worst-case backtracking. + const text = 'a.b/'.repeat(2000) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('still detects a path in a long-but-under-cap run', () => { + const prefix = 'context '.repeat(200) + const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`) + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } + ]) + }) +}) + +describe('isFilePathCodeSpan', () => { + it('accepts a slashed path code span', () => { + expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true) + }) + + it('accepts Windows paths in code spans', () => { + expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true) + }) + + it('accepts a bare filename code span', () => { + expect(isFilePathCodeSpan('package.json')).toBe(true) + }) + + it('rejects multi-word code spans', () => { + expect(isFilePathCodeSpan('npm run build')).toBe(false) + }) + + it('rejects non-file code spans', () => { + expect(isFilePathCodeSpan('someVariable')).toBe(false) + }) + + it('rejects urls in code spans', () => { + expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false) + }) + + it('accepts scoped-package paths with a segment-leading @', () => { + expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true) + expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) + }) + + it('rejects emails and git URLs with a mid-token @', () => { + expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) + expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) + }) +}) + +describe('normalizeFilePath', () => { + it('strips a leading ./', () => { + expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') + expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`) + }) + + it('leaves other paths unchanged', () => { + expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts') + expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts') + }) +}) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts new file mode 100644 index 00000000000..a33c0dceae4 --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -0,0 +1,212 @@ +// Conservative detection of file-path-like tokens inside an inline markdown text +// run, so the chat view can render them as tappable (opening the mobile file +// viewer). We deliberately favor precision over recall: a missed path is a minor +// annoyance, but a false positive on prose or a version number is a broken tap. + +export type FilePathSegment = + | { type: 'text'; value: string } + | { type: 'file'; value: string; path: string } + +// Common source/code/config extensions we treat as openable file paths. Kept +// explicit (rather than "any extension") so prose like "etc." or "e.g." and +// domain-ish tokens like "example.com" don't get matched. +const FILE_EXTENSIONS = [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'md', + 'mdx', + 'markdown', + 'txt', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'kt', + 'kts', + 'swift', + 'c', + 'h', + 'cc', + 'cpp', + 'hpp', + 'cs', + 'php', + 'sh', + 'bash', + 'zsh', + 'fish', + 'yml', + 'yaml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'lock', + 'sql', + 'graphql', + 'gql', + 'proto', + 'xml', + 'svg', + 'vue', + 'svelte', + 'astro', + 'dart', + 'ex', + 'exs', + 'erl', + 'lua', + 'pl', + 'r', + 'scala', + 'clj', + 'gradle', + 'dockerfile', + 'gitignore', + 'npmrc' +] as const + +const EXTENSION_SET = new Set(FILE_EXTENSIONS) + +// Accept the host's native separator because transcript paths originate on the +// connected runtime, which may be Windows even when the phone is not. +const CANDIDATE_PATTERN = + /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + +// A path candidate in chat prose is short; a much longer run can't hold one worth +// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we +// skip detection entirely above this cap. +const MAX_DETECTION_LENGTH = 2000 + +// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such +// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and +// stays eligible. +function hasMidTokenAt(candidate: string): boolean { + return /[^\\/]@/.test(candidate) +} + +function isOpenablePath(candidate: string): boolean { + // Reject anything URL-ish or scheme-bearing — those are handled as web links. + if (candidate.includes('://') || hasMidTokenAt(candidate)) { + return false + } + // Must contain a separator (a bare "file.ts" is too ambiguous in prose). + if (!/[\\/]/.test(candidate)) { + return false + } + const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')) + const lastSegment = candidate.slice(lastSeparator + 1) + const dot = lastSegment.lastIndexOf('.') + // A leading-dot dotfile in the final segment (e.g. ".env") has no extension to + // anchor on; require a real name.ext shape. + if (dot <= 0) { + return false + } + const ext = lastSegment.slice(dot + 1).toLowerCase() + if (!EXTENSION_SET.has(ext)) { + return false + } + // Guard against version-number-ish tails ("1.2.3" style) where the "extension" + // is purely numeric — those aren't files. + if (/^\d+$/.test(ext)) { + return false + } + return true +} + +// Strip the leading ./ marker so callers receive a clean worktree-relative path, +// while keeping ../ (which is meaningful) intact. +export function normalizeFilePath(path: string): string { + return path.replace(/^\.[\\/]/, '') +} + +/** + * Given a plain inline text run, return ordered segments marking which substrings + * are openable file paths. Non-path text is preserved verbatim so the renderer can + * reassemble the run exactly. Returns a single text segment when nothing matches. + */ +export function detectFilePathSegments(text: string): FilePathSegment[] { + // Every real match ends in a name.ext, so a dot is mandatory; skip the regex when + // there is none, or when the run is too long to hold a chat path but long enough + // to drive CANDIDATE_PATTERN into super-linear backtracking. + if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) { + return [{ type: 'text', value: text }] + } + const segments: FilePathSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + CANDIDATE_PATTERN.lastIndex = 0 + + while ((match = CANDIDATE_PATTERN.exec(text))) { + const candidate = match[0] + // Skip candidates that are part of a URL (preceded by a scheme colon or an + // alphanumeric/host char that would make this a domain tail, not a path). + const prev = match.index > 0 ? text[match.index - 1]! : '' + if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + continue + } + if (!isOpenablePath(candidate)) { + continue + } + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) }) + lastIndex = match.index + candidate.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + // Collapse to a single text segment for the common no-match case. + if (segments.length === 0) { + return [{ type: 'text', value: text }] + } + return segments +} + +/** + * True when an inline-code span's entire content is a single openable file path + * (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a + * bare `file.ts` here even without a slash. + */ +export function isFilePathCodeSpan(code: string): boolean { + const trimmed = code.trim() + if (!trimmed || /\s/.test(trimmed)) { + return false + } + if (trimmed.includes('://') || hasMidTokenAt(trimmed)) { + return false + } + if (isOpenablePath(trimmed)) { + return true + } + // Separator-less code span: accept a clean name.ext with a known extension. + if (/[\\/]/.test(trimmed)) { + return false + } + const dot = trimmed.lastIndexOf('.') + if (dot <= 0) { + return false + } + const name = trimmed.slice(0, dot) + const ext = trimmed.slice(dot + 1).toLowerCase() + if (/[^\w.@+-]/.test(name)) { + return false + } + return EXTENSION_SET.has(ext) +} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts new file mode 100644 index 00000000000..414a8daf57b --- /dev/null +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + inlineCodeLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx new file mode 100644 index 00000000000..cb766b0be51 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Animated, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** Animated three-dot "agent is working" row, shown while the active agent is + * still producing a reply. Pure presentation — visibility is the caller's call. */ +export function MobileAgentWorkingIndicator(): React.JSX.Element { + const dots = [ + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current + ] + + useEffect(() => { + const animations = dots.map((dot, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true }) + ]) + ) + ) + animations.forEach((a) => a.start()) + return () => animations.forEach((a) => a.stop()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + Agent is working + + {dots.map((dot, i) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontStyle: 'italic' + }, + dots: { + flexDirection: 'row', + gap: 4 + }, + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx new file mode 100644 index 00000000000..3baa6da8c3a --- /dev/null +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -0,0 +1,79 @@ +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' +import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' + +type BrowserTab = Extract +export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload' + +/** Keeps browser-tab navigation actions out of the session route while preserving + * the target captured at the moment each drawer action is pressed. */ +export function MobileBrowserTabActionSheet(props: { + target: BrowserTab | null + onClose: () => void + onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void + onCloseTab: (target: BrowserTab) => void +}): React.JSX.Element { + const { target, onClose, onNavigate, onCloseTab } = props + return ( + { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.back') + } + } + } + ] + : []), + ...(target?.canGoForward + ? [ + { + label: 'Forward', + icon: ChevronRight, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.forward') + } + } + } + ] + : []), + { + label: 'Reload', + icon: RefreshCw, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.reload') + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const current = target + onClose() + if (current) { + onCloseTab(current) + } + } + } + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx new file mode 100644 index 00000000000..b485818970c --- /dev/null +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -0,0 +1,378 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' + +type Props = { + prompt: AskPrompt + /** Deliver the chosen answer (per-question option indices + free text) — + * index-based so Claude's arrow-navigate selector can be driven by the + * option's stable number instead of pasted label text (STA-1860). */ + onAnswer: (selections: AskAnswerSelection[]) => Promise + onCancel?: () => Promise +} + +// Sentinel index for the free-text "Other…" row (never a real option index). +const OTHER = -1 + +/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one + * question per step with tabs across the top, a Next button that advances (Send + * on the last step), and a Cancel that dismisses the prompt. Neutral styling + * with a subtle green accent on the active choice to match the rest of the app. */ +export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { + const [index, setIndex] = useState(0) + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + const toggle = (qi: number, optIndex: number, multi: boolean): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[qi] ?? [] + if (multi) { + next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex] + } else { + next[qi] = cur.includes(optIndex) ? [] : [optIndex] + } + return next + }) + } + + const setOther = (qi: number, value: string): void => { + setOtherText((prev) => { + const next = [...prev] + next[qi] = value + return next + }) + } + + const selectionFor = (qi: number): AskAnswerSelection => { + const picked = (selections[qi] ?? []).filter((i) => i !== OTHER) + const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' + return other ? { indices: picked, other } : { indices: picked } + } + + const isAnswered = (qi: number): boolean => { + const sel = selectionFor(qi) + return sel.indices.length > 0 || (sel.other ?? '').length > 0 + } + + const total = prompt.questions.length + const isLast = index === total - 1 + const currentAnswered = useMemo( + () => isAnswered(index), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selections, otherText, index] + ) + const allAnswered = useMemo( + () => prompt.questions.every((_, i) => isAnswered(i)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [otherText, prompt.questions, selections] + ) + const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + + const submit = async (): Promise => { + if (!allAnswered || submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + try { + await onAnswer(prompt.questions.map((_, i) => selectionFor(i))) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const advance = async (): Promise => { + if (isLast) { + await submit() + } else { + setIndex((i) => Math.min(i + 1, total - 1)) + } + } + + const q = prompt.questions[index]! + const otherSelected = (selections[index] ?? []).includes(OTHER) + + return ( + + {total > 1 ? ( + + {prompt.questions.map((qq, i) => ( + setIndex(i)} + > + + {qq.header || `Step ${i + 1}`} + + {isAnswered(i) ? ( + + ) : null} + + ))} + + ) : null} + + + {q.question} + {q.options.map((opt, optIndex) => ( + toggle(index, optIndex, q.multiSelect)} + /> + ))} + toggle(index, OTHER, q.multiSelect)} + /> + {otherSelected ? ( + setOther(index, v)} + placeholder="Type your answer" + placeholderTextColor={colors.textMuted} + multiline + autoFocus + /> + ) : null} + + + + { + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + }} + disabled={submitting} + hitSlop={8} + > + Cancel + + {total > 1 ? ( + + {index + 1}/{total} + + ) : null} + + + {isLast ? 'Send answer' : 'Next'} + + + + + ) +} + +function OptionRow({ + label, + description, + selected, + multi, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + maxHeight: 380, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + tabs: { + flexGrow: 0, + paddingTop: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + tabsContent: { + paddingHorizontal: spacing.sm, + gap: spacing.xs, + alignItems: 'center' + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 36, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.statusGreen + }, + tabText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + tabTextActive: { + color: colors.textPrimary + }, + scroll: { + paddingHorizontal: spacing.md + }, + questionText: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + marginVertical: spacing.sm + }, + option: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + input: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + color: colors.textPrimary, + fontSize: typography.bodySize, + padding: spacing.sm, + minHeight: 44, + marginBottom: spacing.xs + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.md, + gap: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + cancel: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + progress: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + next: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radii.button, + backgroundColor: colors.textPrimary + }, + nextDisabled: { + backgroundColor: colors.bgRaised + }, + nextText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + nextTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts new file mode 100644 index 00000000000..ef2c1012603 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -0,0 +1,206 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square' +})) + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatComposer', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render( + onSend: (text: string) => Promise, + onChangeText: () => void, + isAttaching = false + ) { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) + } finally { + restore() + } + } + + function sendButton(): { props: { onPress: () => Promise } } { + if (!renderer) { + throw new Error('Composer was not rendered') + } + return renderer.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) as { props: { onPress: () => Promise } } + } + + it('reports an accepted send without owning route-scoped draft cleanup', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('keeps the draft when the send is rejected', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(false) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('disables send while an attachment path is still being injected', async () => { + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, vi.fn(), true) + + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + + it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true) + }) + ) + }) + } finally { + restore() + } + const input = () => + renderer!.root.find((node) => node.type === 'TextInput') as { + props: { + selection?: { start: number; end: number } + onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void + } + } + // Uncontrolled selection until a suggestion is applied. + expect(input().props.selection).toBeUndefined() + // Place the caret at the end so the slash trigger is active and suggestions render. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) + ) + const firstSuggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => firstSuggestion.props.onPress()) + // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. + expect(input().props.selection).toEqual({ start: 7, end: 7 }) + // The next native selection event releases control so manual placement still works. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } }) + ) + expect(input().props.selection).toBeUndefined() + }) + + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { + const onMicPress = vi.fn() + const onMicPressIn = vi.fn() + const onMicPressOut = vi.fn() + const mic = () => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' + ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) + + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() + } finally { + restore() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx new file mode 100644 index 00000000000..ba02fe3d44b --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -0,0 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +// Common agent slash commands offered as autocomplete; sending them is just text +// to the agent's terminal, so the set is intentionally provider-agnostic. +const SLASH_COMMANDS = [ + '/clear', + '/compact', + '/review', + '/model', + '/help', + '/init', + '/cost', + '/diff' +] + +const NO_FILE_PATHS: string[] = [] + +type Props = { + /** Controlled composer text — owned by the parent so dictation can write to it. */ + value: string + onChangeText: (text: string) => void + onSend: (text: string) => Promise + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + /** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */ + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + disabled?: boolean + placeholder?: string + filePaths?: string[] + onNeedFiles?: (query: string) => void +} + +export function MobileNativeChatComposer({ + value, + onChangeText, + onSend, + onAttachImage, + isAttaching = false, + onMicPress, + micActive = false, + dictationMode = 'toggle', + onMicPressIn, + onMicPressOut, + disabled = false, + placeholder = 'Message, @files, /commands', + filePaths = NO_FILE_PATHS, + onNeedFiles +}: Props): React.JSX.Element { + const [cursor, setCursor] = useState(0) + // Transiently drives the native caret after a mid-text autocomplete insert, + // then released on the next selection change so manual caret placement still + // works (a permanently controlled `selection` breaks it in React Native). + const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( + null + ) + const sendingRef = useRef(false) + const [sending, setSending] = useState(false) + const trimmed = value.trim() + const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + const suggestions = useMemo(() => { + if (!trigger) { + return [] + } + if (trigger.kind === 'slash') { + return rankSuggestions(SLASH_COMMANDS, trigger.query) + } + return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) + }, [trigger, filePaths]) + + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + + const handleChange = (next: string): void => { + onChangeText(next) + } + + const pickSuggestion = (suggestion: string): void => { + if (!trigger) { + return + } + const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + onChangeText(nextText) + setCursor(nextCursor) + setPendingSelection({ start: nextCursor, end: nextCursor }) + } + + const handleSend = async (): Promise => { + if (!canSend || sendingRef.current) { + return + } + sendingRef.current = true + setSending(true) + try { + const accepted = await onSend(trimmed) + if (accepted) { + setCursor(0) + } + } finally { + sendingRef.current = false + setSending(false) + } + } + + return ( + + {suggestions.length > 0 ? ( + + + {suggestions.map((s) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => pickSuggestion(s)} + > + + {s} + + + ))} + + + ) : null} + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + editable={!disabled} + textAlignVertical="top" + /> + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 180 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + bar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + input: { + flex: 1, + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + iconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center' + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + // White send affordance per design — dark arrow on a light circle. + backgroundColor: colors.textPrimary + }, + sendButtonDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx new file mode 100644 index 00000000000..eadfd2043f8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -0,0 +1,371 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { + isImageRefBlock, + isTextBlock, + pairToolBlocks, + splitNativeChatBlocks, + type ToolPair +} from './mobile-native-chat-blocks' +import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' +import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from './mobile-native-chat-tool-summary' + +const MAX_VISIBLE_TOOL_PAIRS = 6 +const MAX_TOOL_RUN_DIFF_ROWS = 240 + +function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { + return ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + + {output.length > MAX_TOOL_RESULT_CHARS + ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` + : output} + + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const preview = call + ? summarizeToolInput(call.input) + : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = call ? toolFilePath(call.input) : null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {expanded ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {expanded ? ( + + {callDiff ? : null} + {!callDiff && call && preview ? {preview} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +function Prose({ + block, + invert, + fontScale, + onOpenFile +}: { + block: NativeChatBlock + invert?: boolean + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element | null { + if (isTextBlock(block)) { + // Inverted (user) bubbles use a fixed dark-on-light text rather than the + // markdown renderer's light-on-dark palette. + if (invert) { + return ( + {block.text} + ) + } + return ( + + ) + } + if (isImageRefBlock(block)) { + return ( + + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} + + ) + } + return null +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +function ToolRun({ + blocks, + defaultExpanded, + trailing, + onOpenFile +}: { + blocks: NativeChatBlock[] + defaultExpanded: boolean + trailing?: React.ReactNode + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [open, setOpen] = useState(defaultExpanded) + const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) + const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) + let callCount = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + callCount++ + } + } + callCount ||= pairs.length + const summary = summarizeToolRun(blocks) + return ( + + + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} + + + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} + +/** Subtle top-right controls for an agent message: copy its prose, or scroll so + * this message's top aligns to the top of the viewport. */ +function AgentControls({ + onCopy, + onScrollToTop +}: { + onCopy: () => void + onScrollToTop?: () => void +}): React.JSX.Element { + return ( + + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onCopy} + hitSlop={8} + accessibilityLabel="Copy message" + > + + + {onScrollToTop ? ( + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onScrollToTop} + hitSlop={8} + accessibilityLabel="Scroll this message to top" + > + + + ) : null} + + ) +} + +function MobileNativeChatMessageImpl({ + message, + queued, + toolsExpanded = false, + fontScale = 1, + messageIndex, + onScrollToMessage, + onOpenFile +}: { + message: NativeChatMessage + queued?: boolean + toolsExpanded?: boolean + /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ + fontScale?: number + /** This message's index in the list, paired with onScrollToMessage. */ + messageIndex?: number + /** Ask the list to align this message's top to the top of the viewport. */ + onScrollToMessage?: (index: number) => void + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const isUser = message.role === 'user' + const isReasoning = message.role === 'reasoning' + const isAgent = !isUser + // Briefly tint the bubble to confirm a copy landed. + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) + useEffect( + () => () => { + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + }, + [] + ) + // Separate the agent's words from its tool activity: prose renders first, the + // tool calls fold into a collapsible run beneath. The user's own messages get + // an inverted (filled accent) bubble so they stand apart from agent prose. + const { prose, tools } = splitNativeChatBlocks(message.blocks) + + const handleCopy = (): void => { + const text = nativeChatMessageText(message.blocks) + if (!text) { + return + } + void Clipboard.setStringAsync(text) + setCopied(true) + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + copyTimer.current = setTimeout(() => setCopied(false), 700) + } + + // Copy + scroll-to-top, shown inline with the first tool call (or after the + // prose when there are no tools). + const controls = + isAgent && !queued ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null + + return ( + + {isUser && queued ? Queued : null} + + {prose.map((block, index) => ( + + ))} + {tools.length > 0 ? ( + + ) : controls ? ( + {controls} + ) : null} + + + ) +} + +export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx new file mode 100644 index 00000000000..6acf2d305f6 --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, View } from 'react-native' +import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +type Props = { + controller: MobileNativeChatController + onAttachImage: () => void + isAttaching: boolean + onMicPress: () => void + micActive: boolean + dictationMode: 'toggle' | 'hold' + onMicPressIn: () => void + onMicPressOut: () => void + inputLockReason: MobileNativeChatInputLockReason | null + keyboardInset: number +} + +/** Keeps the terminal mounted underneath chat so its PTY subscription survives + * view toggles while the native surface owns the visible composer. */ +export function MobileNativeChatOverlay({ + controller, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + keyboardInset +}: Props): React.JSX.Element | null { + if (!controller.showNativeChat) { + return null + } + const session = controller.nativeChatSession + return ( + + + + ) +} + +const styles = StyleSheet.create({ + overlay: StyleSheet.absoluteFillObject +}) diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts new file mode 100644 index 00000000000..8e0223f19ec --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) + +describe('MobileNativeChatPermission', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('accepts only one response when two presses land in the same render batch', async () => { + let resolveResponse: (accepted: boolean) => void = () => {} + const response = new Promise((resolve) => (resolveResponse = resolve)) + const onRespond = vi.fn(() => response) + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) + }) + } finally { + consoleSpy.mockRestore() + } + const button = renderer.root.findByType('Pressable') + + act(() => { + button.props.onPress() + button.props.onPress() + }) + + expect(onRespond).toHaveBeenCalledOnce() + await act(async () => resolveResponse(true)) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx new file mode 100644 index 00000000000..ad26d578d93 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -0,0 +1,123 @@ +import { memo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileChatPermission } from './mobile-native-chat-permission' + +// Renders a detected agent permission ask as a card with tappable options. +// The first option is treated as the primary (allow) action and gets a filled +// accent button so the affirmative choice reads as distinct from the rest. +function MobileNativeChatPermissionImpl({ + permission, + onRespond +}: { + permission: MobileChatPermission + onRespond: (send: string) => Promise +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const respond = async (send: string): Promise => { + if (submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + const accepted = await onRespond(send) + if (!accepted) { + submittingRef.current = false + setSubmitting(false) + } + } + return ( + + + + {permission.title} + + {permission.detail ? {permission.detail} : null} + + {permission.options.map((option, index) => { + const isPrimary = index === 0 + return ( + [ + styles.option, + isPrimary ? styles.optionPrimary : styles.optionSecondary, + pressed && !submitting && styles.optionPressed + ]} + hitSlop={6} + onPress={() => respond(option.send)} + disabled={submitting} + > + + {option.label} + + + ) + })} + + + ) +} + +export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl) + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + detail: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: typography.metaSize + 5 + }, + options: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + option: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + optionPrimary: { + backgroundColor: colors.accentBlue + }, + optionSecondary: { + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionPressed: { + opacity: 0.7 + }, + optionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionTextPrimary: { + color: colors.onAccent + } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx new file mode 100644 index 00000000000..f470214dbed --- /dev/null +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' + +type Props = { + question: MobileChatQuestion + onAnswer: (text: string) => Promise +} + +/** Renders an agent's choice prompt as a tappable card. Single-select answers + * on tap; multi-select toggles then Submits; an always-present text entry lets + * the user answer freely (the escape hatch) when the heuristic misreads the + * options or none apply. */ +export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { + const [selected, setSelected] = useState([]) + const [freeText, setFreeText] = useState('') + const [sending, setSending] = useState(false) + const sendingRef = useRef(false) + + const hasOptions = question.options.length > 0 + const trimmedFreeText = freeText.trim() + + const toggle = (option: string): void => { + setSelected((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] + ) + } + + const sendAnswer = async (text: string): Promise => { + if (sendingRef.current) { + return false + } + sendingRef.current = true + setSending(true) + try { + return await onAnswer(text) + } finally { + sendingRef.current = false + setSending(false) + } + } + + const answerSingle = async (option: string): Promise => { + await sendAnswer(formatQuestionAnswer(question, [option])) + } + + const submitMulti = async (): Promise => { + if (selected.length === 0) { + return + } + await sendAnswer(formatQuestionAnswer(question, selected)) + } + + const submitFreeText = async (): Promise => { + if (trimmedFreeText.length === 0) { + return + } + // Free text is an unknown entry; formatQuestionAnswer passes it through. + if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + setFreeText('') + } + } + + const canSubmitMulti = selected.length > 0 && !sending + const canSendFreeText = trimmedFreeText.length > 0 && !sending + + // Stable keys for option rows even if an agent repeats a label. + const optionRows = useMemo( + () => question.options.map((label, index) => ({ label, key: `${index}:${label}` })), + [question.options] + ) + + return ( + + + + {question.question} + + + {hasOptions ? ( + + {optionRows.map(({ label, key }) => { + const isSelected = selected.includes(label) + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.pressed + ]} + onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))} + > + {question.multiSelect ? ( + + {isSelected ? : null} + + ) : null} + {label} + + ) + })} + + ) : null} + + {question.multiSelect && hasOptions ? ( + [ + styles.submit, + !canSubmitMulti && styles.submitDisabled, + pressed && canSubmitMulti && styles.pressed + ]} + onPress={submitMulti} + disabled={!canSubmitMulti} + > + + Submit{selected.length > 0 ? ` (${selected.length})` : ''} + + + ) : null} + + + + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + question: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + lineHeight: typography.bodySize + 7 + }, + options: { + gap: spacing.xs + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionSelected: { + borderColor: colors.accentBlue + }, + optionText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1 + }, + checkbox: { + width: 20, + height: 20, + borderRadius: radii.button, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkboxOn: { + backgroundColor: colors.accentBlue, + borderColor: colors.accentBlue + }, + submit: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.accentBlue + }, + submitDisabled: { + backgroundColor: colors.bgRaised + }, + submitText: { + color: colors.onMergeGreen, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + submitTextDisabled: { + color: colors.textMuted + }, + freeTextRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeSend: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.textPrimary + }, + freeSendDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx new file mode 100644 index 00000000000..1cfe37e5d4d --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Text, + View +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatAsk } from './MobileNativeChatAsk' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** 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' + +type Props = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ + agent?: string | null + agentWorking?: boolean + /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ + onStop?: () => void + /** Live partial assistant text while a turn is still streaming (from the agent + * status hook). Shown as an in-progress bubble until the transcript catches up. */ + streamingText?: string + hasMore?: boolean + loadingEarlier?: boolean + onLoadEarlier?: () => void + onSend: (text: string) => Promise + /** Optimistic queued sends (owned by the route so they survive view switches). */ + pending: Array<{ id: string; text: string }> + /** Controlled composer text (owned by the route so dictation can write to it). */ + composerText: string + onComposerTextChange: (text: string) => void + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + inputLockReason?: MobileNativeChatInputLockReason | null + filePaths?: string[] + onNeedFiles?: (query: string) => void + /** A pending agent question/permission detected from live status, shown as a + * native card above the composer; answering sends text to the agent. */ + /** Structured AskUserQuestion prompt parsed from the transcript (preferred over + * the heuristic question card). */ + ask?: AskPrompt | null + /** Deliver the ask answer as per-question selections; the send hook turns them + * into selector keystrokes (Claude) or pasted label text (other agents). */ + onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + onCancelAsk?: () => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + /** Open a worktree file tapped in agent markdown. */ + onOpenFile?: (relativePath: string) => void + /** Pixels to lift the composer by when the soft keyboard is open. The route + * owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */ + keyboardInset?: number +} + +export function MobileNativeChatView({ + messages, + status, + error, + agent, + agentWorking, + onStop, + streamingText, + hasMore, + loadingEarlier, + onLoadEarlier, + onSend, + pending, + composerText, + onComposerTextChange, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + filePaths, + onNeedFiles, + ask, + onAnswerAsk, + onCancelAsk, + question, + onAnswerQuestion, + permission, + onRespondPermission, + onOpenFile, + keyboardInset = 0 +}: Props): React.JSX.Element { + const insets = useSafeAreaInsets() + const listRef = useRef>(null) + const [toolsExpanded, setToolsExpanded] = useState(false) + // Dismiss the question card as soon as it's answered; the live status lingers + // briefly (the agent emits a post-tool event with the same prompt), so hide it + // until a genuinely different question arrives. + const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) + // Lift the composer clear of the keyboard, plus the bottom safe-area so it + // never sits under the home indicator / nav bar (mirrors the terminal dock). + const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom + const [atBottom, setAtBottom] = useState(true) + const sendScrollTimerRef = useRef | null>(null) + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() + // Surface a rejected send inline above the composer — a bottom toast gets hidden + // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. + const [sendFailed, setSendFailed] = useState(false) + useEffect(() => { + if (!sendFailed) { + return + } + const t = setTimeout(() => setSendFailed(false), 4000) + return () => clearTimeout(t) + }, [sendFailed]) + + useEffect( + () => () => { + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + }, + [] + ) + + const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) + // `data` is the list source: folded transcript + synthetic streaming bubble + + // route-owned optimistic queued messages. Memoize on the same deps so the + // downstream autoscroll effects/`renderItem` keep referential stability. + const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const { data } = useMemo( + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), + [foldedMessages, streamingText, pending] + ) + + // Follow the tail as the conversation grows and keep the newest message above + // the keyboard when it opens — but only when already pinned to the bottom, so + // we don't yank the user away while they read history. (Also fires on keyboard + // close, which is harmless while atBottom.) + useEffect(() => { + if (data.length === 0 || !atBottom) { + return + } + const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) + return () => clearTimeout(t) + }, [data.length, atBottom, keyboardInset]) + + const handleSend = useCallback( + async (text: string): Promise => { + const accepted = await onSend(text) + if (!accepted) { + setSendFailed(true) + return false + } + setSendFailed(false) + // Always jump to the newest message when the user sends. + setAtBottom(true) + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + sendScrollTimerRef.current = setTimeout(() => { + sendScrollTimerRef.current = null + listRef.current?.scrollToEnd({ animated: true }) + }, 60) + return true + }, + [onSend] + ) + + const onScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) + setAtBottom(distanceFromBottom < 80) + // Near the top — page in older history. + if (contentOffset.y < 60 && hasMore && !loadingEarlier) { + onLoadEarlier?.() + } + }, + [hasMore, loadingEarlier, onLoadEarlier] + ) + + // Align a single message's top to the top of the viewport. + const onScrollToMessage = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true }) + }, []) + + const renderItem = useCallback( + ({ item, index }: { item: NativeChatMessage; index: number }) => ( + + ), + [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + ) + + 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. + const rawLockReason = inputLockReason ?? null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockReason === null) { + setLockHeld(false) + return + } + const timer = setTimeout(() => setLockHeld(true), 600) + return () => clearTimeout(timer) + }, [rawLockReason]) + const lockReason = lockHeld ? rawLockReason : null + + return ( + + {showLoading ? ( + + + + ) : ( + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={32} + onContentSizeChange={() => { + if (data.length > 0 && atBottom) { + listRef.current?.scrollToEnd({ animated: false }) + } + }} + // scrollToIndex can fail before an off-screen row is measured — + // fall back to an estimated offset, then retry once it's laid out. + onScrollToIndexFailed={(info) => { + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true + }) + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + viewPosition: 0, + animated: true + }) + }, 120) + }} + ListHeaderComponent={ + hasMore ? ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) : null + } + ListEmptyComponent={ + emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + } + /> + + {/* Jump-to-latest control. The scroll-to-top affordance now lives + per-message (the up-arrow in each agent message's controls). */} + {!atBottom ? ( + listRef.current?.scrollToEnd({ animated: true })} + > + + + ) : null} + + )} + {/* Pending agent prompt: a structured AskUserQuestion wins, then a + heuristic permission, then a heuristic question. */} + {showAsk && ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) : null} + {/* Chrome row above the composer: the working indicator and the global + tool-calls expand/collapse toggle on the left, Stop in the far corner. */} + + + {agentWorking ? : null} + [styles.chromeToggle, pressed && styles.pressed]} + onPress={() => setToolsExpanded((v) => !v)} + hitSlop={8} + > + {toolsExpanded ? ( + + ) : ( + + )} + {toolsExpanded ? 'Collapse' : 'Tools'} + + + {agentWorking ? ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + hitSlop={8} + accessibilityLabel="Stop the agent" + > + + Stop + + ) : null} + + {sendFailed ? ( + + + {rawLockReason === 'disconnected' + ? 'Message not sent — reconnecting…' + : 'Message not sent'} + + + ) : null} + + + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 1bf3df59e74..9d725d5fe60 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess { } function clientWithResponses(responses: RpcResponse[]): Pick & { - calls: Array<{ method: string; params: unknown }> + calls: { method: string; params: unknown }[] } { - const calls: Array<{ method: string; params: unknown }> = [] + const calls: { method: string; params: unknown }[] = [] return { calls, sendRequest: vi.fn(async (method: string, params?: unknown) => { @@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/orca-attach.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) const sent = await attachMobileImageToTerminal('library', { @@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/x.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('files', { @@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/y.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('library', { @@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => { expect(sendCall?.params).not.toHaveProperty('client') }) - it('waits for pending live input before sending the image payload', async () => { + it('drops the image payload when the final pre-send check observes an input lease gap', async () => { const client = clientWithResponses([ { id: 'start', @@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => { }, ok('save', '/tmp/pending.png') ]) + // Why: upload can outlive the stream subscription whose acknowledgement + // originally enabled the composer. const beforeTerminalSend = vi.fn(async () => false) const sent = await attachMobileImageToTerminal('library', { @@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => { expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) }) + + it('reports failure when the terminal rejects the uploaded image path', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/rejected.png'), + ok('send', { send: { accepted: false } }) + ]) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-rejected', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' }) + }) + + expect(sent).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index b7d14d9a04d..567be99a9a1 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -4,6 +4,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' export type AttachMobileImageDeps = { readonly client: Pick @@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal( if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { return false } - await client.sendRequest('terminal.send', { + const response = await client.sendRequest('terminal.send', { terminal, text: payload, enter: false, ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {}) }) - return true + return isTerminalSendRpcAccepted(response) } diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts new file mode 100644 index 00000000000..05e05b1f188 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mobileNativeChatQuestionOffsets, + scheduleMobileClaudeAnswer, + MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, + MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, + MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS +} from './mobile-native-chat-answer-stepping' + +describe('mobileNativeChatQuestionOffsets', () => { + it('matches the desktop cadence constants', () => { + expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) + }) + + it('paces each question a full step apart, Enter 500ms after its body', () => { + expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) + }) +}) + +describe('scheduleMobileClaudeAnswer', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('writes each question body then its Enter, paced per step, one Enter per line', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['', 'b', 'c'], // blank middle answer must still get its own body + Enter + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + expect(timers).toHaveLength(6) + + vi.advanceTimersByTime(0) + expect(events).toEqual(['body:']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(events).toEqual(['body:', 'enter']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) + expect(events).toEqual(['body:', 'enter', 'body:b']) + + vi.runAllTimers() + expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) + }) + + it('cancelling the returned timers stops all pending writes', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['a', 'b'], + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + for (const timer of timers) { + clearTimeout(timer) + } + vi.runAllTimers() + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.ts b/mobile/src/session/mobile-native-chat-answer-stepping.ts new file mode 100644 index 00000000000..441f41291d7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.ts @@ -0,0 +1,13 @@ +import { + nativeChatQuestionOffsets, + scheduleNativeChatAnswer, + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../src/shared/native-chat-answer-stepping' + +export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS +export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS +export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS +export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets +export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts new file mode 100644 index 00000000000..6654a0e8689 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildAskAnswerKeys, + extractPendingAsk, + formatAskAnswer, + parseAskFromStatus +} from './mobile-native-chat-ask' + +function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } +} + +const askInput = { + questions: [ + { + question: 'Pick one', + header: 'Choice', + multiSelect: false, + options: [ + { label: 'A', description: 'first' }, + { label: 'B', description: 'second' } + ] + } + ] +} + +describe('extractPendingAsk', () => { + it('returns the structured prompt from a pending AskUserQuestion call', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }]) + ]) + expect(ask?.questions[0]).toMatchObject({ + question: 'Pick one', + header: 'Choice', + multiSelect: false + }) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null once a tool-result follows the ask', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'), + msg([{ type: 'tool-result', output: 'answered' }], 'r') + ]) + expect(ask).toBeNull() + }) + + it('ignores non-ask tool calls', () => { + expect( + extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])]) + ).toBeNull() + }) + + it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => { + // A parallel Bash call precedes the ask; its result resolves the Bash call + // (oldest outstanding), so the unanswered question must remain pending. + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1') + ]) + expect(ask?.questions[0]!.question).toBe('Pick one') + }) + + it("clears the ask only when the ask's own result arrives", () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1'), + msg([{ type: 'tool-result', output: 'answered' }], 'r2') + ]) + expect(ask).toBeNull() + }) + + it('keeps the latest ask when several appear', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'x' }], 'r1'), + msg([ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }] + } + } + ]) + ]) + expect(ask?.questions[0]!.question).toBe('Second') + }) +}) + +describe('parseAskFromStatus', () => { + it('parses the live interactivePrompt JSON into a prompt', () => { + const ask = parseAskFromStatus(JSON.stringify(askInput)) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null for empty or malformed input', () => { + expect(parseAskFromStatus(undefined)).toBeNull() + expect(parseAskFromStatus('')).toBeNull() + expect(parseAskFromStatus('{not json')).toBeNull() + expect(parseAskFromStatus('{"foo":1}')).toBeNull() + }) +}) + +describe('formatAskAnswer', () => { + it('joins selected labels per question', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }] } + ] + } + expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC') + }) + + it('keeps one line per question with a blank middle answer (3 questions)', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'B' }] }, + { question: 'q3', multiSelect: false, options: [{ label: 'C' }] } + ] + } + const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }]) + expect(answer).toBe('A\n\nC') + expect(answer.split('\n')).toHaveLength(3) + }) +}) + +describe('buildAskAnswerKeys', () => { + it('answers a single-select pick with its option number only', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }]) + }) + + it('toggles multi-select numbers then steps to Submit and confirms', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\x1b[C' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts new file mode 100644 index 00000000000..50766b25ee4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.ts @@ -0,0 +1,2 @@ +// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. +export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts new file mode 100644 index 00000000000..25c904c7b13 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +describe('detectAutocompleteTrigger', () => { + it('detects a slash command only at the start', () => { + expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ + kind: 'slash', + query: 'rev', + start: 0, + end: 4 + }) + expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() + }) + + it('detects an @ mention after whitespace or at start', () => { + expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) + expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ + kind: 'file', + query: 'comp' + }) + }) + + it('does not trigger @ mid-word (email-like)', () => { + expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() + }) + + it('closes the token once a space is typed', () => { + expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() + }) + + it('returns empty query right after the trigger char', () => { + expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) + }) +}) + +describe('applyAutocomplete', () => { + it('replaces the trigger span and leaves a trailing space + cursor', () => { + const trigger = detectAutocompleteTrigger('look at @comp', 13)! + const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') + expect(text).toBe('look at @src/App.tsx ') + expect(cursor).toBe(text.length) + }) +}) + +describe('rankSuggestions', () => { + it('prefers prefix matches on the basename', () => { + const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') + expect(out[0]).toBe('src/AppBar.tsx') + expect(out).toContain('lib/zapp.ts') + }) + + it('returns the head of the list for an empty query', () => { + expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts new file mode 100644 index 00000000000..de0ddc91f67 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -0,0 +1,93 @@ +// Why: the composer offers two kinds of autocomplete — `@file` mentions and +// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's +// unit-testable and the composer stays a thin view over it. + +export type AutocompleteKind = 'file' | 'slash' + +export type AutocompleteTrigger = { + kind: AutocompleteKind + /** The query typed after the trigger char (may be empty). */ + query: string + /** Index of the trigger char in the text (inclusive). */ + start: number + /** Index just past the cursor / token end (exclusive) — the replace span end. */ + end: number +} + +const TOKEN_CHAR = /[^\s]/ + +/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers + * anywhere it follows whitespace/start; a `/` command only at the very start of + * the input (slash commands are line-leading). Returns null when the cursor is + * not inside such a token. */ +export function detectAutocompleteTrigger( + text: string, + cursor: number +): AutocompleteTrigger | null { + const pos = Math.max(0, Math.min(cursor, text.length)) + // Walk left from the cursor over non-whitespace token chars to find the trigger. + let i = pos - 1 + while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { + i-- + } + const triggerIndex = i + 1 + const triggerChar = text[triggerIndex] + if (triggerChar !== '@' && triggerChar !== '/') { + return null + } + const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! + if (triggerChar === '/' && triggerIndex !== 0) { + // Slash commands are only offered at the very start of the message. + return null + } + if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { + return null + } + const query = text.slice(triggerIndex + 1, pos) + // A space in the query means the token already closed. + if (/\s/.test(query)) { + return null + } + return { + kind: triggerChar === '@' ? 'file' : 'slash', + query, + start: triggerIndex, + end: pos + } +} + +/** Replace the trigger span with `value`, leaving a trailing space and the + * cursor after it. Returns the new text and the new cursor position. */ +export function applyAutocomplete( + text: string, + trigger: AutocompleteTrigger, + value: string +): { text: string; cursor: number } { + const inserted = `${value} ` + const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) + return { text: next, cursor: trigger.start + inserted.length } +} + +/** Rank suggestions for a query: case-insensitive, prefix matches first, then + * substring matches, capped. Used for both file paths and command names. */ +export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { + const q = query.toLowerCase() + if (q.length === 0) { + return candidates.slice(0, limit) + } + const prefix: string[] = [] + const substring: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase() + const base = lower.split('/').pop() ?? lower + if (lower.startsWith(q) || base.startsWith(q)) { + prefix.push(candidate) + } else if (lower.includes(q)) { + substring.push(candidate) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts new file mode 100644 index 00000000000..57351e4a20c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks +} from './mobile-native-chat-blocks' + +function msg( + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + id: string = role +): NativeChatMessage { + return { id, role, blocks, timestamp: 0, source: 'transcript' } +} + +describe('splitNativeChatBlocks', () => { + it('separates prose from tool blocks', () => { + const { prose, tools } = splitNativeChatBlocks([ + { type: 'text', text: 'hi' }, + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(prose.map((b) => b.type)).toEqual(['text']) + expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result']) + }) +}) + +describe('pairToolBlocks', () => { + it('pairs each call with the following result', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a\nb' }, + { type: 'tool-call', name: 'Edit', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[0]!.call?.name).toBe('Bash') + expect(pairs[0]!.result?.output).toBe('a\nb') + expect(pairs[1]!.call?.name).toBe('Edit') + }) + + it('keeps an orphan result on its own', () => { + const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }]) + expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }]) + }) + + it('does not attach a second result to an already-filled pair', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: '1' }, + { type: 'tool-result', output: '2' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[1]!.call).toBeUndefined() + expect(pairs[1]!.result?.output).toBe('2') + }) + + it('pairs batched parallel calls by ordinal, not adjacency', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + // Parallel calls stream as [call, call, result, result]; the Nth result + // answers the Nth call, so result1 must land on call1 (not the nearer call2). + const pairs = pairToolBlocks([call1, call2, result1, result2]) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('does not graft a dropped over-limit call result onto a kept call', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + const result3 = { type: 'tool-result' as const, output: 'out-c' } + const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + // call3 was dropped past the limit, so result3 must not misgraft onto call2. + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('retains the last result while bounding rendered pairs', () => { + const blocks = Array.from({ length: 20 }, (_unused, index) => [ + { type: 'tool-call' as const, name: 'Bash', input: { index } }, + { type: 'tool-result' as const, output: String(index) } + ]).flat() + const pairs = pairToolBlocks(blocks, 6) + + expect(pairs).toHaveLength(6) + expect(pairs[5].result?.output).toBe('5') + }) +}) + +describe('foldToolMessages', () => { + it('folds tool-call-only assistant messages and result messages into the prose turn', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'), + msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'), + msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2') + ]) + expect(folded).toHaveLength(1) + expect(folded[0]!.blocks.map((b) => b.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result', + 'tool-call', + 'tool-result' + ]) + }) + + it('starts a new turn at the next prose assistant message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 't1' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('assistant', [{ type: 'text', text: 't2' }], 'a2') + ]) + expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) + }) + + it('keeps a tool message standalone when no assistant precedes it', () => { + const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) + expect(folded).toHaveLength(1) + expect(folded[0]!.role).toBe('tool') + }) + + it('does not merge across a user message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'), + msg('user', [{ type: 'text', text: 'q' }], 'u'), + msg('tool', [{ type: 'tool-result', output: 'r' }], 't') + ]) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + }) + + it('folds a long tool run without mutating the source assistant', () => { + const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') + const tools = Array.from({ length: 1_000 }, (_unused, index) => + msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) + ) + const folded = foldToolMessages([assistant, ...tools]) + + expect(folded[0].blocks).toHaveLength(1_001) + expect(assistant.blocks).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts new file mode 100644 index 00000000000..1cf293a79fd --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.ts @@ -0,0 +1,11 @@ +export { + isImageRefBlock, + isTextBlock, + NATIVE_CHAT_SOURCE_PRIORITY +} from '../../../src/shared/native-chat-types' +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair as ToolPair +} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts new file mode 100644 index 00000000000..3171b818050 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' + +describe('diffFromToolCall', () => { + it('builds del/add lines from an Edit tool call', () => { + const lines = diffFromToolCall('Edit', { + file_path: 'src/a.ts', + old_string: 'const a = 1\nconst b = 2', + new_string: 'const a = 10' + }) + expect(lines).toEqual([ + { kind: 'meta', text: 'src/a.ts' }, + { kind: 'del', text: 'const a = 1' }, + { kind: 'del', text: 'const b = 2' }, + { kind: 'add', text: 'const a = 10' } + ]) + }) + + it('returns null for non-edit tools', () => { + expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull() + }) +}) + +describe('diffFromText', () => { + it('parses unified diff lines with colours', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' context' }, + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ]) + }) + + it('does not treat ordinary prose as a diff', () => { + expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() + }) + + it('caps large diffs before creating render rows', () => { + const text = Array.from( + { length: 1_000 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + const lines = diffFromText(text) + + expect(lines).toHaveLength(120) + expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) + }) + + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts new file mode 100644 index 00000000000..4ace06d37d0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.ts @@ -0,0 +1,6 @@ +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts new file mode 100644 index 00000000000..387736f065e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { + canShowMobileNativeChat, + isMobileNativeChatTranscriptReadable, + resolveMobileNativeChat +} from './mobile-native-chat-eligibility' + +function status(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: 'tab:leaf', + ...overrides + } as AgentStatusEntry +} + +describe('resolveMobileNativeChat', () => { + it('prefers the authoritative supported live agent over a stale launch hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' } + } + } as never) + ).toMatchObject({ agent: 'codex', sessionId: 'codex-session' }) + }) + + it('rejects an unsupported live agent instead of combining it with a stale hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'gemini', + providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' } + } + } as never) + ).toBeNull() + }) + it('resolves agent + sessionId from launchAgent and provider session', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: status({ + providerSession: { + key: 'session_id', + id: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + } + }) + }) + ).toEqual({ + agent: 'claude', + sessionId: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + }) + }) + + it('falls back to agentStatus.agentType when no launchAgent', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + agentStatus: status({ agentType: 'codex' }) + }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null }) + }) + + it('admits OpenClaude with its distinct agent identity', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({ + agent: 'openclaude', + sessionId: null, + transcriptPath: null + }) + }) + + it('returns null for unsupported agents', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() + }) + + it('admits Grok only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'grok' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'grok' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'grok' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + }) + + it('returns null for a plain shell (no agent)', () => { + expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() + }) + + it('returns null for non-terminal tabs', () => { + expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() + }) + + it('canShowMobileNativeChat mirrors resolution', () => { + expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) + expect(canShowMobileNativeChat(null)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts new file mode 100644 index 00000000000..84e0e00d012 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -0,0 +1,66 @@ +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' + +// 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. +export function isMobileNativeChatTranscriptReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} + +export type MobileNativeChatResolution = { + agent: string + /** The agent's own session id, or null before it has reported one (the view + * then shows a waiting state instead of trying to read an unaddressable file). */ + sessionId: string | null + /** Hook-reported transcript path. Recent Claude sessions cannot always be + * resolved from the provider session id, so mobile forwards this to runtime. */ + transcriptPath: string | null +} + +export type MobileNativeChatTab = { + type: string + launchAgent?: string | null + agentStatus?: AgentStatusEntry | null +} + +/** Resolve a session tab to the transcript identity native chat needs, or + * null when the tab can't show native chat (not a terminal, no agent, or an + * agent whose transcript the host can't read). Agent comes from the launch + * hint or the live status; session id from the captured provider session. */ +export function resolveMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): MobileNativeChatResolution | null { + if (!tab || tab.type !== 'terminal') { + return null + } + const liveAgent = tab.agentStatus?.agentType ?? null + const agent = liveAgent + ? isNativeChatSupportedAgent(liveAgent) + ? liveAgent + : null + : tab.launchAgent + if (!agent || !isNativeChatSupportedAgent(agent)) { + return null + } + if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + return null + } + return { + agent, + sessionId: tab.agentStatus?.providerSession?.id ?? null, + transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null + } +} + +/** Whether the tab can toggle into native chat — gates the long-press item. */ +export function canShowMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): boolean { + return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null +} diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts new file mode 100644 index 00000000000..61312670512 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mergeNativeChatMessages } from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +describe('mergeNativeChatMessages', () => { + it('appends new ids in arrival order', () => { + const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('replaces a re-emitted id in place without reordering', () => { + const merged = mergeNativeChatMessages( + [msg('a'), msg('b'), msg('c')], + [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] + ) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) + }) + + it('does not let a lower-priority source overwrite a higher one', () => { + const merged = mergeNativeChatMessages( + [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], + [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] + ) + expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) + }) + + it('returns the existing array unchanged for an empty batch', () => { + const existing = [msg('a')] + expect(mergeNativeChatMessages(existing, [])).toBe(existing) + }) + + it('does not mutate the existing array', () => { + const existing = [msg('a')] + mergeNativeChatMessages(existing, [msg('b')]) + expect(existing.map((m) => m.id)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts new file mode 100644 index 00000000000..6b6ffe1781e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.ts @@ -0,0 +1,9 @@ +// Keep the established mobile import path while executing the shared desktop/mobile merger. +export { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList, + type NativeChatMerger +} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts new file mode 100644 index 00000000000..2a034fd33f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the +// pure mergeNativeChatMessages for every prefix of an adversarial subscribe +// sequence — the cache must never diverge from a full rebuild (#18). +describe('mobile stateful merger — oracle vs pure merge', () => { + const base = [msg('a'), msg('b', { source: 'hook' })] + const batches: NativeChatMessage[][] = [ + [msg('c')], // pure tail + [], // empty frame + [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede + [msg('b', { source: 'scrape' })], // lower priority — ignored + [msg('a')], // re-emit, no change + [msg('d'), msg('e')] // multi tail + ] + + it('matches the pure merge for every prefix', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + let pure = mergeNativeChatMessages(base, []) + expect(merger.list).toEqual(pure) + for (const batch of batches) { + const out = applyAppend(merger, batch) + pure = mergeNativeChatMessages(pure, batch) + expect(out).toEqual(pure) + } + }) + + it('keeps prior row identity on a pure tail append', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + const before = merger.list + const tail = msg('z') + const after = applyAppend(merger, [tail]) + expect(after).not.toBe(before) + expect(after[0]).toBe(before[0]) + expect(after[after.length - 1]).toBe(tail) + }) + + it('bounds the window to the most-recent tail', () => { + const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`)) + expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9']) + expect(boundNativeChatWindow(long, 0)).toBe(long) + expect(boundNativeChatWindow(long, 20)).toBe(long) + }) + + it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, [msg('a'), msg('b'), msg('c')]) + + expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) + expect(merger.indexById.has('a')).toBe(false) + expect(merger.indexById.get('c')).toBe(0) + expect( + applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] + ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts new file mode 100644 index 00000000000..c67ffb738cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -0,0 +1,168 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const TEXT_SIZE = 17 +export const MONO_SIZE = 12 +export const MAX_TOOL_RESULT_CHARS = 4000 + +export const styles = StyleSheet.create({ + row: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + rowUser: { + alignItems: 'flex-end' + }, + content: { + maxWidth: '100%', + gap: spacing.sm + }, + userBubble: { + maxWidth: '88%', + backgroundColor: colors.textPrimary, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + userText: { + color: colors.bgBase, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6, + fontWeight: '500' + }, + controls: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginBottom: 2, + opacity: 0.7 + }, + controlButton: { + padding: 3 + }, + controlPressed: { + opacity: 0.5 + }, + copied: { + backgroundColor: colors.diffAddedBg, + borderRadius: radii.card + }, + reasoning: { + opacity: 0.7 + }, + queued: { + opacity: 0.55 + }, + queuedTag: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + marginBottom: 2 + }, + toolRun: { + marginTop: spacing.xs + }, + toolRunHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + toolRunToggle: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + toolRunCount: { + color: colors.statusGreen, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '700' + }, + toolRunLabel: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolRunBody: { + paddingLeft: spacing.sm, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + marginTop: spacing.xs + }, + toolLine: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + toolName: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + 1, + fontWeight: '600' + }, + toolPreview: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolPreviewLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + toolDetail: { + paddingLeft: spacing.lg, + paddingBottom: spacing.xs, + gap: spacing.xs + }, + mono: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5 + }, + toolResult: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md + }, + toolResultError: { + backgroundColor: colors.diffDeletedBg + }, + imageRef: { + color: colors.textSecondary, + fontSize: TEXT_SIZE + }, + diff: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.xs, + overflow: 'hidden' + }, + diffLine: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5, + paddingHorizontal: spacing.sm + }, + diffAdd: { + color: colors.gitDecorationAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.gitDecorationDeleted, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts new file mode 100644 index 00000000000..225904cc5d8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { + clampFontScale, + FONT_SCALE_MAX, + FONT_SCALE_MIN, + nativeChatMessageText +} from './mobile-native-chat-message-text' + +describe('nativeChatMessageText', () => { + it('joins text blocks and skips non-text blocks', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'text', text: 'World' } + ] + expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld') + }) + + it('returns an empty string when there is no prose', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }] + expect(nativeChatMessageText(blocks)).toBe('') + }) + + it('trims surrounding whitespace', () => { + expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi') + }) +}) + +describe('clampFontScale', () => { + it('clamps below the minimum', () => { + expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) + }) + + it('clamps above the maximum', () => { + expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) + }) + + it('passes through an in-range value', () => { + expect(clampFontScale(1.2)).toBe(1.2) + }) + + it('falls back to 1 for NaN', () => { + expect(clampFontScale(Number.NaN)).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts new file mode 100644 index 00000000000..26aeea7680c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -0,0 +1,24 @@ +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { isTextBlock } from './mobile-native-chat-blocks' + +/** Concatenate a message's text blocks into a single copyable string. Tool + * calls/results and image refs are skipped — Copy is for the agent's prose. */ +export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string { + return blocks + .filter(isTextBlock) + .map((b) => b.text) + .join('\n\n') + .trim() +} + +/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ +export const FONT_SCALE_MIN = 0.8 +export const FONT_SCALE_MAX = 1.8 + +/** Clamp a proposed font scale into the supported range. */ +export function clampFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) +} diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts new file mode 100644 index 00000000000..6977b764e72 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' + +function msg( + role: NativeChatMessage['role'], + text: string, + blocks?: NativeChatMessage['blocks'] +): NativeChatMessage { + return { + id: text.slice(0, 8), + role, + blocks: blocks ?? [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +describe('isNoiseMessage', () => { + it('flags task-notification and system-reminder user turns', () => { + expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) + expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) + expect( + isNoiseMessage( + msg( + 'user', + 'Caveat: The messages below were generated by the user while running local commands' + ) + ) + ).toBe(true) + expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) + }) + + it('flags inter-agent harness turns (teammate/agent messages)', () => { + expect(isNoiseMessage(msg('user', 'ping'))).toBe(true) + expect(isNoiseMessage(msg('user', 'status update'))).toBe(true) + }) + + it('keeps real user messages', () => { + expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + // A genuine custom tag is not a known harness tag, so it stays a user turn. + expect(isNoiseMessage(msg('user', ' is my band'))).toBe(false) + }) + + it('keeps assistant and tool turns', () => { + expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + }) + + it('keeps a user turn that carries tool results', () => { + expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) + }) + + it('stripNoiseMessages removes only the noise', () => { + const out = stripNoiseMessages([ + msg('user', 'hello'), + msg('user', 'done'), + msg('assistant', 'hi') + ]) + expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts new file mode 100644 index 00000000000..1b15a7e414a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.ts @@ -0,0 +1 @@ +export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts new file mode 100644 index 00000000000..2f8ceb936a7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + openMobileNativeChatFile, + resolveMobileNativeChatWorktreePath +} from './mobile-native-chat-open-file' + +describe('resolveMobileNativeChatWorktreePath', () => { + it('resolves an absolute tool path to a worktree-relative open target', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + ).resolves.toBe('src/app.ts') + expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { + worktree: 'id:worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + }) + + it('opens only the resolved worktree-relative target', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockResolvedValueOnce({ ok: true, result: {} }) + + await openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '../repo/src/app.ts', + terminal: 'terminal' + }) + + expect(sendRequest).toHaveBeenLastCalledWith('files.open', { + worktree: 'id:worktree', + relativePath: 'src/app.ts' + }) + }) + + it('resolves null when the resolve request rejects', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeNull() + }) + + it('does not reject when the open request fails', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockRejectedValueOnce(new Error('connection interrupted')) + + await expect( + openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts new file mode 100644 index 00000000000..44d6d23c066 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -0,0 +1,51 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { RpcClient } from '../transport/rpc-client' + +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + try { + const response = await args.client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${args.worktreeId}`, + pathText: args.pathText, + ...(args.terminal ? { terminal: args.terminal } : {}) + }) + if (!response.ok) { + return null + } + const resolved = response.result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return null + } + return resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + } catch { + // Callers fire-and-forget file opens; a disconnect/timeout must not become + // an unhandled rejection. + return null + } +} + +export async function openMobileNativeChatFile(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + const relativePath = await resolveMobileNativeChatWorktreePath(args) + if (relativePath) { + try { + await args.client.sendRequest('files.open', { + worktree: `id:${args.worktreeId}`, + relativePath + }) + } catch { + // Best-effort open; failures surface as a no-op rather than an + // unhandled rejection. + } + } +} diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts new file mode 100644 index 00000000000..2592038d7c9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send' + +describe('sendMobileNativeChatPermissionResponse', () => { + it('writes an approval as raw bytes without appending Return', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: 'phone', + text: '1' + }) + ).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts new file mode 100644 index 00000000000..679be2d94ed --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -0,0 +1,49 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +export function sendMobileNativeChatPermissionResponse(args: { + client: RpcClient + terminal: string + deviceToken: string | null + text: string +}): Promise { + // Why: approval choices are already complete terminal control sequences; + // appending Return changes both numbered choices and Escape denial. + return sendMobileNativeChatMessage({ + client: args.client, + terminal: args.terminal, + text: args.text, + enter: false, + ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) + }) +} + +export function useMobileNativeChatPermissionSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + onSendError: (message: string) => void +}): (text: string) => Promise { + return useCallback( + async (text: string): Promise => { + const terminal = args.handleRef.current + if (!args.client || !terminal || !args.enabled) { + args.onSendError('Response not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + if (!accepted) { + args.onSendError('Response not sent') + } + return accepted + }, + [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts new file mode 100644 index 00000000000..4744c394756 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' + +describe('detectAgentPermission', () => { + it('returns null for a working agent even with permission-like text', () => { + expect( + detectAgentPermission({ + state: 'working', + lastAssistantMessage: 'Do you want to proceed? (y/n)' + }) + ).toBeNull() + }) + + it('returns null when state is undefined', () => { + expect( + detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' }) + ).toBeNull() + }) + + it('returns null for ordinary prose while blocked', () => { + expect( + detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I finished editing the file and ran the tests.' + }) + ).toBeNull() + }) + + it('returns null when the message is empty', () => { + expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull() + }) + + it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)' + }) + expect(result).not.toBeNull() + expect(result?.title).toBe('Permission requested') + expect(result?.options).toEqual([ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ]) + expect(result?.detail).toContain('Allow running') + }) + + it('detects a numbered allow/deny menu and sends the digit', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Claude wants to edit App.tsx. Do you want to proceed?\n' + + '1. Yes\n' + + '2. No, and tell Claude what to do differently' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' }) + expect(result?.options[1]?.send).toBe('2') + expect(result?.options[1]?.label).toContain('No') + }) + + it('handles parenthesized numbered options "1) Yes 2) No"', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No' + }) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2']) + }) + + it('supports a three-option numbered menu (Yes / Yes always / No)', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + "Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No" + }) + expect(result?.options).toHaveLength(3) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3']) + }) + + it('adds an "Allow always" option when the text offers a persistent grant', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: + 'Do you want to allow this? You can allow always for this session. (y/n)' + }) + expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny']) + expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n']) + }) + + it('detects keyword-only permission asks without explicit y/n tokens', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I need your permission to run this Bash command.' + }) + expect(result).not.toBeNull() + expect(result?.options.map((o) => o.send)).toEqual(['y', 'n']) + }) + + it('detects "approve" / "deny" phrasing', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + }) + + it('truncates a very long detail line', () => { + const long = `Do you want to proceed? ${'x'.repeat(300)}` + const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long }) + expect(result).not.toBeNull() + expect((result?.detail ?? '').length).toBeLessThanOrEqual(160) + }) + + it('shortens long numbered option labels', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong' + }) + const second = result?.options[1] + expect(second?.send).toBe('2') + expect((second?.label ?? '').length).toBeLessThanOrEqual(40) + }) +}) + +describe('parseApprovalFromStatus', () => { + it('parses an approval envelope into an Allow/Deny card', () => { + const card = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + expect(card?.title).toBe('Allow Bash?') + expect(card?.detail).toBe('rm -rf build') + expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny']) + expect(card?.options[0]!.send).toBe('1') + }) + + it('returns null for non-approval / malformed input', () => { + expect(parseApprovalFromStatus(undefined)).toBeNull() + expect(parseApprovalFromStatus('{bad')).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts new file mode 100644 index 00000000000..fe3f9a0d89b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -0,0 +1,179 @@ +// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface +// as plain TUI text in the agent's last assistant message — there is no +// structured permission event on mobile. We detect them heuristically so the +// native chat can render tappable Allow/Deny buttons instead of forcing the +// user to type into the composer. Be conservative: only fire when the agent is +// actually paused (blocked/waiting) AND the text reads like an approval ask. + +/** A detected permission prompt, rendered as a card with tappable options. + * Each option's `send` is the literal string to write back to the agent + * (e.g. "y", "1") when the user taps it. */ +export type MobileChatPermission = { + title: string + detail?: string + options: Array<{ label: string; send: string }> +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the live `agentStatus.interactivePrompt` approval envelope + * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) + * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike + * detectAgentPermission it doesn't depend on heuristic text parsing. The default + * sends (number for allow, Escape for deny) match the common TUI approval prompt; + * detectAgentPermission still takes precedence when it can read the real numbered + * options from the prompt text. */ +export function parseApprovalFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const approval = (parsed as { approval?: unknown }).approval + if (!approval || typeof approval !== 'object') { + return null + } + const tool = (approval as { tool?: unknown }).tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = (approval as { summary?: unknown }).summary + return { + title: `Allow ${tool}?`, + detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: ESCAPE } + ] + } +} + +type PermissionInput = { + state?: string + lastAssistantMessage?: string + toolName?: string + toolInput?: unknown +} + +// States where the agent is paused waiting on the human. Only these can yield a +// permission prompt — a "working" agent is mid-turn and must not be answered. +const PAUSED_STATES: ReadonlySet = new Set(['blocked', 'waiting']) + +// Phrases that read as an approval request. Kept broad but anchored to +// approval/permission language so ordinary prose doesn't trip detection. +const PERMISSION_PATTERNS: RegExp[] = [ + /\bpermission\b/i, + /\bapprove\b/i, + /\bapproval\b/i, + /\ballow\b/i, + /\bdeny\b/i, + /\bgrant\b/i, + /\bauthorize\b/i, + /\bdo you want to\b/i, + /\bwould you like to\b/i, + /\bproceed\?/i, + /\bconfirm\b/i, + /\(y\/n\)/i, + /\by\/n\b/i, + /\byes\/no\b/i, + /\bplease confirm\b/i +] + +function looksLikePermissionAsk(text: string): boolean { + return PERMISSION_PATTERNS.some((re) => re.test(text)) +} + +// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…". +// Captures the option number and its label so we can send the literal digit. +const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g + +type NumberedOption = { num: string; text: string } + +function parseNumberedOptions(text: string): NumberedOption[] { + const out: NumberedOption[] = [] + for (const match of text.matchAll(NUMBERED_OPTION_RE)) { + const num = match[1] + const body = match[2]?.trim() + if (num && body) { + out.push({ num, text: body }) + } + } + return out +} + +// Whether a label reads as "allow for every future call" rather than just once. +function isAlwaysLabel(text: string): boolean { + return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text) +} + +function shortLabel(text: string, max = 40): string { + const trimmed = text.replace(/\s+/g, ' ').trim() + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed +} + +function buildNumberedPermission( + options: NumberedOption[], + detail: string | undefined +): MobileChatPermission { + return { + title: 'Permission requested', + detail, + options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num })) + } +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? '' +} + +/** + * Heuristically detect an agent permission ask from its paused-state context. + * Returns a renderable prompt, or null when the agent is working or the text + * doesn't read like an approval request. + */ +export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null { + // Only answer a genuinely paused agent. A working agent is mid-turn. + if (!input.state || !PAUSED_STATES.has(input.state)) { + return null + } + + const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : '' + if (!text.trim()) { + return null + } + + if (!looksLikePermissionAsk(text)) { + return null + } + + const detail = shortLabel(firstLine(text), 160) || undefined + + // Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels + // and send-digits come straight from the agent, so no guessing. + const numbered = parseNumberedOptions(text) + if (numbered.length >= 2) { + return buildNumberedPermission(numbered, detail) + } + + // Otherwise fall back to a y/n prompt. We surface "Allow always" only when the + // text actually offers a persistent option, to avoid sending a token the agent + // doesn't understand. + const options: MobileChatPermission['options'] = [ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ] + if (isAlwaysLabel(text)) { + options.splice(1, 0, { label: 'Allow always', send: 'a' }) + } + + return { title: 'Permission requested', detail, options } +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts new file mode 100644 index 00000000000..94fbcf055a9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswer, + mobileChatQuestionKey, + parseAgentQuestion, + type MobileChatQuestion +} from './mobile-native-chat-question' + +describe('parseAgentQuestion', () => { + it('parses a numbered list with a question line', () => { + const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join( + '\n' + ) + const q = parseAgentQuestion(text) + expect(q).not.toBeNull() + expect(q?.question).toBe('Which database should I use?') + expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite']) + expect(q?.optionTokens).toEqual(['1', '2', '3']) + expect(q?.multiSelect).toBe(false) + }) + + it('parses numbered lists using ) markers', () => { + const q = parseAgentQuestion('Pick:\n1) Yes\n2) No') + expect(q?.options).toEqual(['Yes', 'No']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('parses a bulleted list', () => { + const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash']) + expect(q?.optionTokens).toEqual([null, null, null]) + expect(q?.question).toBe('How should we proceed?') + }) + + it('parses lettered options in brackets and parens', () => { + const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta') + expect(q?.options).toEqual(['Alpha', 'Beta']) + expect(q?.optionTokens).toEqual(['a', 'b']) + + const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta') + expect(q2?.options).toEqual(['Alpha', 'Beta']) + expect(q2?.optionTokens).toEqual(['a', 'b']) + }) + + it('strips a leading pointer/highlight glyph from the selected row', () => { + const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['main', 'develop']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('drops a trailing colon but keeps a question mark in the title', () => { + expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options') + expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?') + }) + + it('returns null for ordinary prose with no option list', () => { + expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull() + expect(parseAgentQuestion('')).toBeNull() + expect(parseAgentQuestion(' ')).toBeNull() + }) + + it('returns null for a single bare option with no introducing prompt', () => { + // A lone "- item" in prose must not be treated as a question. + expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull() + }) + + it('accepts a single option when introduced by a prompt line', () => { + const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it') + expect(q).not.toBeNull() + expect(q?.options).toEqual(['Yes, apply it']) + }) + + it('uses a fallback title when no introducing line exists', () => { + const q = parseAgentQuestion('1. Alpha\n2. Beta') + expect(q?.question).toBe('Choose an option') + expect(q?.options).toEqual(['Alpha', 'Beta']) + }) + + it('detects multi-select hints', () => { + expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false) + }) + + it('does not flag multi-select when only one option is present', () => { + const q = parseAgentQuestion('Select all that apply?\n1. Only one') + expect(q?.multiSelect).toBe(false) + }) + + it('finds the question line nearest the options, skipping blanks', () => { + const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.question).toBe('Which branch?') + }) +}) + +describe('formatQuestionAnswer', () => { + const numbered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta', 'Gamma'], + multiSelect: false, + optionTokens: ['1', '2', '3'] + } + + it('echoes the leading token for single-select', () => { + expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2') + }) + + it('comma-joins tokens for multi-select', () => { + const multi: MobileChatQuestion = { ...numbered, multiSelect: true } + expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3') + }) + + it('sends the label text when the option had no token (bullet list)', () => { + const bullets: MobileChatQuestion = { + question: 'Pick', + options: ['Rebase', 'Merge'], + multiSelect: false, + optionTokens: [null, null] + } + expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge') + }) + + it('echoes letter tokens', () => { + const lettered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta'], + multiSelect: false, + optionTokens: ['a', 'b'] + } + expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b') + }) + + it('passes free-text / unknown entries through verbatim', () => { + expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom') + }) + + it('returns empty string when nothing is selected', () => { + expect(formatQuestionAnswer(numbered, [])).toBe('') + expect(formatQuestionAnswer(numbered, [' '])).toBe('') + }) +}) + +describe('mobileChatQuestionKey', () => { + it('changes when a replacement prompt changes any positional content', () => { + const first: MobileChatQuestion = { + question: 'Pick', + options: ['A', 'B'], + multiSelect: true, + optionTokens: ['1', '2'] + } + expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( + mobileChatQuestionKey(first) + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts new file mode 100644 index 00000000000..8a1f06dfbf7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -0,0 +1,154 @@ +// Heuristic detection of an agent's "pick an option" prompt from its status / +// assistant text. Agents (Claude et al.) render these as a TUI choice list; we +// have no structured signal, so we parse the text conservatively and only treat +// it as a question when a clear option list is present. + +export type MobileChatQuestion = { + question: string + options: string[] + multiSelect: boolean + /** Per-option leading marker ("1", "b", …) when the source line carried one, + * parallel to `options`. Null where the option was a plain bullet. Used to + * echo the exact choice the agent listed back to the terminal. */ + optionTokens: (string | null)[] +} + +export function mobileChatQuestionKey(question: MobileChatQuestion): string { + return JSON.stringify(question) +} + +type ParsedOption = { + label: string + token: string | null +} + +// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row. +// Stripped first so "❯ 2. Foo" parses identically to "2. Foo". +const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/ + +// Ordered most-specific-first so a numbered/lettered marker wins over the +// bullet fallback. `token` is the capture index of the leading marker (0 = none), +// `label` the capture index of the choice text. +const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [ + // 1. Option 12) Option + { re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // [a] Option [1] Option + { re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // a) Option a. Option (single letter, to avoid eating prose like "e.g.") + { re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // - Option * Option • Option > Option (no token) + { re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 } +] + +function parseOptionLine(line: string): ParsedOption | null { + const stripped = line.replace(POINTER_PREFIX, '$1') + for (const { re, token, label } of OPTION_PATTERNS) { + const m = stripped.match(re) + if (!m) { + continue + } + const text = m[label].trim() + if (text.length === 0) { + continue + } + return { label: text, token: token > 0 ? m[token] : null } + } + return null +} + +const MULTI_SELECT_HINT = + /\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i + +// Question-like introducing line: ends in ? or :. +const QUESTION_LINE = /[?:]\s*$/ + +// Drop a trailing ":" off a card title but keep a meaningful "?". +function cleanQuestionText(raw: string): string { + const trimmed = raw.trim() + return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed +} + +/** + * Heuristically parse a question + its option list from agent text. Returns null + * when no clear option list is present (so ordinary prose is never treated as a + * question). Conservative on purpose: requires at least two option lines, or one + * option line introduced by a question-like prompt line. + */ +export function parseAgentQuestion(text: string): MobileChatQuestion | null { + if (typeof text !== 'string' || text.trim().length === 0) { + return null + } + + const lines = text.replace(/\r\n/g, '\n').split('\n') + const parsed: { index: number; option: ParsedOption }[] = [] + + lines.forEach((line, index) => { + const option = parseOptionLine(line) + if (option) { + parsed.push({ index, option }) + } + }) + + if (parsed.length === 0) { + return null + } + + const firstOptionIndex = parsed[0].index + const options = parsed.map((p) => p.option.label) + const optionTokens = parsed.map((p) => p.option.token) + + // Find the introducing question: nearest non-empty, non-option line above the + // first option. + let question = '' + let questionLooksLikePrompt = false + for (let i = firstOptionIndex - 1; i >= 0; i--) { + if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) { + continue + } + question = lines[i] + questionLooksLikePrompt = QUESTION_LINE.test(lines[i]) + break + } + + // Conservative gate: a single bare option with no introducing prompt is more + // likely stray prose (a lone "- item") than a real choice list. + if (options.length < 2 && !questionLooksLikePrompt) { + return null + } + + const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1 + + return { + question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option', + options, + multiSelect, + optionTokens + } +} + +/** + * Build the text to send to the agent terminal for the selected option(s). + * Convention: echo the option's leading marker (number/letter) when the list had + * one; otherwise send the option label text (TUIs accept the literal choice). + * Multi-select answers are comma-joined; single-select is sent as-is. Unknown + * entries (free-text escape hatch) pass through verbatim. Returns '' when + * nothing is selected. + */ +export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string { + const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0) + if (labels.length === 0) { + return '' + } + + const parts = labels.map((label) => { + const index = question.options.indexOf(label) + if (index === -1) { + // Free-text / unknown entry: pass the user's text straight through. + return label + } + const token = question.optionTokens[index] + return token != null && token.length > 0 ? token : label + }) + + return parts.join(question.multiSelect ? ', ' : ' ') +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts new file mode 100644 index 00000000000..e933971220d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatData, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' } +} + +describe('mobileNativeChatEmptyState', () => { + it('invites a first message naming the agent, matching desktop copy', () => { + // waiting-session (live agent, no transcript) and ready (loaded, empty) both + // resolve to the shared "empty" copy with the agent label substituted. + const waiting = mobileNativeChatEmptyState('waiting-session', 'claude') + expect(waiting).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') + }) + + it('falls back to "the agent" when the agent is unknown', () => { + expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( + 'Start a chat with the agent' + ) + }) + + it('prefers the provided error message over the default subtitle', () => { + expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') + expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + }) + + it('returns null for states that show no empty copy', () => { + expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull() + expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull() + }) +}) + +describe('buildMobileNativeChatData', () => { + it('appends pending optimistic messages at the tail as user turns', () => { + const { data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'hello')], + streamingText: undefined, + pending: [{ id: 'p1', text: 'queued' }] + }) + const last = data[data.length - 1] + expect(last.id).toBe('p1') + expect(last.role).toBe('user') + expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }]) + }) + + it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [user('u1', 'hi')], + streamingText: 'thinking out loud', + pending: [] + }) + expect(streaming).toBe('thinking out loud') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('shows a short new streaming reply even after a longer previous turn', () => { + // The last folded turn is a long completed reply; a short new stream must not + // be suppressed just for being shorter than the prior turn. + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], + streamingText: 'Ok', + pending: [] + }) + expect(streaming).toBe('Ok') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('drops the streaming bubble once the real assistant turn already contains it', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'done answer')], + streamingText: 'done', + pending: [] + }) + expect(streaming).toBeNull() + expect(data.some((m) => m.id === 'streaming')).toBe(false) + }) + + it('returns no streaming bubble for empty/whitespace streaming text', () => { + expect( + buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming + ).toBeNull() + expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts new file mode 100644 index 00000000000..74ca01939ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -0,0 +1,115 @@ +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { + formatNativeChatEmptyStateCopy, + type NativeChatEmptyStateCopy +} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from './mobile-native-chat-blocks' +import { stripNoiseMessages } from './mobile-native-chat-noise' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** The centered empty-state copy for a chat with no messages, mirroring the + * desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces + * stay in lockstep. Returns null when the list should stay bare (idle, or the + * loading spinner owns the view). */ +export function mobileNativeChatEmptyState( + status: MobileNativeChatStatus, + agent: string | null, + error?: string +): NativeChatEmptyStateCopy | null { + const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' + switch (status) { + // A live agent with no transcript yet — and a loaded-but-empty transcript — + // are both "start a chat"; invite the first message instead of implying the + // agent is still starting up. + case 'waiting-session': + case 'ready': + return formatNativeChatEmptyStateCopy('empty', agentLabel) + case 'error': { + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + return error ? { ...copy, subtitle: error } : copy + } + default: + return null + } +} + +/** Derive the list data from the raw transcript: fold tool turns into the + * assistant turn, optionally append a synthetic streaming bubble, then the + * route-owned optimistic "queued" messages at the tail. Returns the + * intermediate `folded`/`streaming` so the caller can memoize on them. */ +export function buildMobileNativeChatData({ + messages, + streamingText, + pending +}: { + messages: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const folded = foldMobileNativeChatMessages(messages) + return buildMobileNativeChatTransientData({ folded, streamingText, pending }) +} + +export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + return foldToolMessages(stripNoiseMessages(messages)) +} + +export function buildMobileNativeChatTransientData({ + folded, + streamingText, + pending +}: { + folded: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + // Only show the streaming bubble while its text leads the transcript — once the + // real assistant turn lands with the same text, drop the synthetic one. + const streaming = deriveStreaming(folded, streamingText) + const data: NativeChatMessage[] = [ + ...folded, + ...(streaming + ? [ + { + id: 'streaming', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: streaming }], + timestamp: null, + source: 'hook' as const + } + ] + : []), + ...pending.map((p) => ({ + id: p.id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: p.text }], + timestamp: null, + source: 'transcript' as const + })) + ] + return { folded, streaming, data } +} + +function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { + const text = streamingText?.trim() + if (!text) { + return null + } + const last = folded[folded.length - 1] + const lastText = + last?.role === 'assistant' + ? last.blocks + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join('') + .trim() + : '' + // Hide the synthetic bubble only once the real turn has landed leading with the + // streamed text. A bare length compare would suppress a short new reply behind a + // longer previous turn; a completed prior turn won't start with the new prefix. + if (lastText.startsWith(text)) { + return null + } + return text +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts new file mode 100644 index 00000000000..01f4d5289ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +function clientWithResponse(response: unknown): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +describe('sendMobileNativeChatMessage', () => { + it('returns true only when the terminal accepts the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + mobileClient: { id: 'device', type: 'mobile' } + }) + ).resolves.toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }) + }) + + it('returns false when the terminal rejects the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('returns false when the RPC fails', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('sends a single non-submitting Escape for prompt cancellation', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts new file mode 100644 index 00000000000..4cf1776843e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -0,0 +1,27 @@ +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' + +type MobileTerminalClient = { + id: string + type: 'mobile' +} + +export async function sendMobileNativeChatMessage(args: { + client: RpcClient + terminal: string + text: string + enter?: boolean + mobileClient?: MobileTerminalClient +}): Promise { + try { + const response = await args.client.sendRequest('terminal.send', { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }) + return isTerminalSendRpcAccepted(response) + } catch { + return false + } +} diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 00000000000..8d7d1e7e07a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('uses the first snapshot as the ordered base and carries pagination state', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }) + }) + + it('merges reconnect snapshots and live appends into the bounded window', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + limit: 3, + replaceSnapshot: false + }) + + expect(result).toMatchObject({ + kind: 'messages', + messages: [message('b'), message('c'), message('d')], + cursorInvalidated: true + }) + }) + + it('keeps the pagination cursor when an append does not trim history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [message('b')] }, + limit: 3, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('a'), message('b')] }) + }) + + it('replaces stale history for an explicit transcript replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'replacement', messages: [message('new')], hasMore: false }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + }) + + it('surfaces snapshot errors and ignores unrelated frames', () => { + const merger = createNativeChatMerger() + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', error: 'Transcript unavailable' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'error', error: 'Transcript unavailable' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'error', message: 'Socket closed' }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'error', error: 'Socket closed' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'end' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'ignored' }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts new file mode 100644 index 00000000000..690994f02f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -0,0 +1,63 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' + +export type MobileNativeChatStreamFrame = { + type?: string + messages?: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + error?: string + message?: string +} + +export type AppliedMobileNativeChatFrame = + | { kind: 'ignored' } + | { kind: 'error'; error: string } + | { + kind: 'messages' + messages: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + cursorInvalidated?: boolean + } + +/** Applies runtime stream frames while preserving the initial-snapshot versus + * reconnect-replay distinction owned by the session hook. */ +export function applyMobileNativeChatStreamFrame(args: { + merger: NativeChatMerger + frame: MobileNativeChatStreamFrame + limit: number + replaceSnapshot: boolean +}): AppliedMobileNativeChatFrame { + const { merger, frame, limit, replaceSnapshot } = args + if (frame.type === 'error') { + return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } + } + if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { + return { kind: 'ignored' } + } + if (frame.error) { + return { kind: 'error', error: frame.error } + } + if (!Array.isArray(frame.messages)) { + return { kind: 'ignored' } + } + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + replaceList(merger, frame.messages) + return { + kind: 'messages', + messages: merger.list, + hasMore: frame.hasMore, + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + } + const previousFirstId = merger.list[0]?.id + const messages = applyAppend(merger, frame.messages, limit) + return { + kind: 'messages', + messages, + // Why: once the bounded live window drops its oldest row, the snapshot's + // byte cursor no longer describes the oldest retained message. + ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..cc92c6b3aca --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalCoveredByNativeChat, + mobileNativeChatTerminalCapabilities, + resolveMobileNativeChatTerminalStreamAction +} from './mobile-native-chat-terminal-stream' + +const base = { + showNativeChat: false, + activeHandle: 'pty-1', + activeTabType: 'terminal', + streamActive: true, + streamCovered: false, + webViewReady: true +} + +describe('mobile native-chat terminal stream lifecycle', () => { + it('pauses an active terminal stream while chat covers it', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe( + 'pause' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false + }) + ).toBe('pause') + expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true) + expect(mobileNativeChatTerminalCapabilities(true)).toEqual({ + terminalBinaryStream: 1, + mobileInputLeaseOnly: 1 + }) + expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) + }) + + it('records a cold-start cover before WebView readiness so return refreshes', () => { + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + webViewReady: false + }) + ).toBe('pause') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: true, + streamCovered: true + }) + ).toBe('resume') + }) + + it('resumes only the ready active terminal after chat closes', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe( + 'resume' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: false, + webViewReady: false + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe( + 'resume' + ) + }) + + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { + expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe( + 'none' + ) + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( + 'none' + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..387fe9c3580 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -0,0 +1,37 @@ +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' + +/** Decides whether the active mobile terminal stream should run while native chat + * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ +export function resolveMobileNativeChatTerminalStreamAction(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + streamActive: boolean + streamCovered: boolean + webViewReady: boolean +}): MobileNativeChatTerminalStreamAction { + if (!args.activeHandle || args.activeTabType !== 'terminal') { + return 'none' + } + if (args.showNativeChat) { + return !args.streamCovered ? 'pause' : 'none' + } + return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' +} + +export function isTerminalCoveredByNativeChat( + showNativeChat: boolean, + activeHandle: string | null, + handle: string +): boolean { + return showNativeChat && activeHandle === handle +} + +export function mobileNativeChatTerminalCapabilities(covered: boolean): { + terminalBinaryStream: 1 + mobileInputLeaseOnly?: 1 +} { + return covered + ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + : { terminalBinaryStream: 1 } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts new file mode 100644 index 00000000000..32ae700671a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -0,0 +1,37 @@ +import { MessageSquare, SquareTerminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' + +type ToggleTab = MobileNativeChatTab & { + id: string + terminal: string | null +} + +/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */ +export function getMobileNativeChatToggleActions(args: { + terminalHandle: string | null + tabs: readonly ToggleTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onClose: () => void + onToggle: (tabId: string) => void +}): ActionSheetAction[] { + const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const tab = terminalHandle + ? tabs.find((candidate) => candidate.terminal === terminalHandle) + : null + if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { + return [] + } + const isChat = chatTabIds.has(tab.id) + return [ + { + label: isChat ? 'Switch to terminal view' : 'Switch to chat view', + icon: isChat ? SquareTerminal : MessageSquare, + onPress: () => { + onClose() + onToggle(tab.id) + } + } + ] +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..d08deec4b31 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' + +describe('summarizeToolInput', () => { + it('passes short strings through, collapsing whitespace', () => { + expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp') + }) + + it('serializes objects to compact JSON', () => { + expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}') + }) + + it('truncates long previews with an ellipsis', () => { + const long = 'x'.repeat(200) + const out = summarizeToolInput(long) + expect(out.length).toBe(80) + expect(out.endsWith('…')).toBe(true) + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeToolInput(null)).toBe('') + expect(summarizeToolInput(undefined)).toBe('') + }) + + it('handles numbers and booleans', () => { + expect(summarizeToolInput(42)).toBe('42') + expect(summarizeToolInput(true)).toBe('true') + }) + + it('does not traverse an unbounded tool-input object for a short preview', () => { + const input: Record = Object.fromEntries( + Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index]) + ) + const lateGetter = vi.fn(() => 'should not be read') + Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter }) + + expect(summarizeToolInput(input)).not.toBe('') + expect(lateGetter).not.toHaveBeenCalled() + }) +}) + +describe('toolFilePath', () => { + it('extracts file_path / filePath / path / notebook_path', () => { + expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts') + expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts') + expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts') + expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb') + }) + it('returns null when there is no file target', () => { + expect(toolFilePath({ command: 'ls' })).toBeNull() + expect(toolFilePath('x')).toBeNull() + expect(toolFilePath(null)).toBeNull() + }) +}) + +describe('briefToolArg', () => { + it('takes the basename of a forward-slash path', () => { + expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') + }) + + it('takes the basename of a Windows backslash path from a raw transcript', () => { + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts new file mode 100644 index 00000000000..bc3f7a2f89a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -0,0 +1,7 @@ +export { + briefToolArg, + countToolCalls, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts new file mode 100644 index 00000000000..160477cfad8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -0,0 +1,109 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + }, + chromeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 28, + paddingHorizontal: spacing.md + }, + chromeLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stopButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + stopLabel: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '700' + }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, + chromeToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: spacing.xs + }, + chromeToggleLabel: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + pressed: { + opacity: 0.6 + }, + listWrap: { + flex: 1, + position: 'relative' + }, + listContent: { + paddingVertical: spacing.sm, + flexGrow: 1 + }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyTitle: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.xs + }, + emptySubtitle: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + fab: { + position: 'absolute', + right: spacing.md, + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + fabBottom: { + bottom: spacing.md + }, + loadEarlier: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + minHeight: 36 + }, + loadEarlierText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts new file mode 100644 index 00000000000..3b29a6dfaf7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -0,0 +1,69 @@ +import { Eraser, Monitor, Smartphone } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action' + +type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } + +/** Builds the terminal long-press menu without adding another action block to the + * already dense session route. Native chat stays first as the view switch. */ +export function getMobileTerminalActionSheetActions(args: { + target: Target | null + tabs: readonly TerminalTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onDismiss: () => void + onToggleChat: (tabId: string) => void + isPhoneMode: (handle: string) => boolean + onToggleDisplayMode: (handle: string) => void + onRename: (target: Target) => void + onClear: (target: Target) => void + onClose: (target: Target) => void +}): ActionSheetAction[] { + const { target } = args + if (!target) { + return [] + } + const phoneMode = args.isPhoneMode(target.handle) + return [ + ...getMobileNativeChatToggleActions({ + terminalHandle: target.handle, + tabs: args.tabs, + chatTabIds: args.chatTabIds, + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, + onClose: args.onDismiss, + onToggle: args.onToggleChat + }), + { + label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone', + icon: phoneMode ? Monitor : Smartphone, + onPress: () => { + args.onDismiss() + args.onToggleDisplayMode(target.handle) + } + }, + { + label: 'Rename', + onPress: () => { + args.onDismiss() + args.onRename(target) + } + }, + { + label: 'Clear Terminal', + icon: Eraser, + onPress: () => { + args.onDismiss() + args.onClear(target) + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + args.onDismiss() + args.onClose(target) + } + } + ] +} diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.test.ts b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts new file mode 100644 index 00000000000..8d3785efdcf --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' + +describe('subscribeMobileTerminalSafely', () => { + it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => { + const onSynchronousError = vi.fn() + const client = { + subscribe: vi.fn(() => { + throw new Error('socket closed') + }) + } as unknown as Pick + + const unsubscribe = subscribeMobileTerminalSafely( + client, + { terminal: 'terminal-1' }, + vi.fn(), + onSynchronousError + ) + + expect(onSynchronousError).toHaveBeenCalledOnce() + expect(unsubscribe).not.toThrow() + }) +}) diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.ts b/mobile/src/session/mobile-terminal-stream-subscribe.ts new file mode 100644 index 00000000000..189b07a74f0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.ts @@ -0,0 +1,17 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function subscribeMobileTerminalSafely( + client: Pick, + params: unknown, + onData: Parameters[2], + onSynchronousError: () => void +): () => void { + try { + return client.subscribe('terminal.subscribe', params, onData) + } catch { + // Why: a transport mock or closing socket can reject before returning an + // unsubscribe handle; callers must still release their subscribing marker. + onSynchronousError() + return () => {} + } +} diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts new file mode 100644 index 00000000000..a69d66accdd --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' + +type Gate = (targetHandle: string) => Promise + +describe('useMobileAttachmentInputLeaseGate', () => { + let renderer: ReactTestRenderer | null = null + let errorSpy: ReturnType | null = null + + beforeEach(() => { + const original = console.error + errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...(args as Parameters)) + }) + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + errorSpy?.mockRestore() + }) + + function renderGate(args: { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + showToast: (message: string, durationMs?: number) => void + }): { gate: () => Gate } { + let gate: Gate = () => Promise.resolve(false) + function Probe(): null { + gate = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true), + connStateRef: args.connState, + activeHandleRef: args.activeHandle, + activeSessionTabTypeRef: args.tabType, + nativeChatInputLeaseReadyRef: args.leaseReady, + showToast: args.showToast + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { gate: () => gate } + } + + function baseRefs(): { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + } { + return { + connState: { current: 'connected' }, + activeHandle: { current: 'terminal-1' }, + tabType: { current: 'terminal' }, + leaseReady: { current: true } + } + } + + it('passes immediately when the lease is ready and the target is active', async () => { + const refs = baseRefs() + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + await expect(gate()('terminal-1')).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('waits out a lease-not-ready window and then sends', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('surfaces a toast when the lease never recovers', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500) + }) + + it('drops silently when the target changes while waiting for the lease', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + // Mid-wait the user switches tabs: the lease recovers, but for a different + // target — the attach must not proceed against the stale handle. + await vi.advanceTimersByTimeAsync(200) + refs.activeHandle.current = 'terminal-2' + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) + + it('drops silently when the connection is lost while waiting', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.connState.current = 'reconnecting' + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts new file mode 100644 index 00000000000..a0ae29b6c51 --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' + +type CurrentRef = { readonly current: T } + +type AttachmentInputLeaseGateArgs = { + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly connStateRef: CurrentRef + readonly activeHandleRef: CurrentRef + readonly activeSessionTabTypeRef: CurrentRef + readonly nativeChatInputLeaseReadyRef: CurrentRef + readonly showToast: (message: string, durationMs?: number) => void +} + +// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or +// return-to-terminal) during which the input lease is briefly not ready. +const LEASE_READY_POLL_MS = 100 +const LEASE_READY_TIMEOUT_MS = 3000 + +/** Gates an image attachment's terminal.send on a ready input lease. Flushes any + * pending IME/live input, confirms the send still targets the connected terminal + * tab, then waits out a short lease-not-ready window so a finished upload isn't + * dropped as if the picker were cancelled. Returns false only when the lease + * never recovers — surfacing a toast, since the caller treats a bare false as a + * silent picker-cancel (no error path). */ +export function useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast +}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise { + return useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + if ( + !flushedPendingInput || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + const deadline = Date.now() + LEASE_READY_TIMEOUT_MS + while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS)) + } + // Why: the wait can outlive the target too — re-check so a tab/host switch + // or disconnect mid-wait doesn't send into the wrong (or dead) terminal. + // A moved-away target drops silently like the pre-wait guard; only a lease + // that never recovered warrants the toast. + if ( + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + if (nativeChatInputLeaseReadyRef.current) { + return true + } + showToast('Attach failed (reconnecting)', 1500) + return false + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + connStateRef, + flushPendingLiveInputBeforeExternalSend, + nativeChatInputLeaseReadyRef, + showToast + ] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts new file mode 100644 index 00000000000..79a11b6d723 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -0,0 +1,243 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +type AnswerSend = ReturnType + +function acceptedResponse() { + return { + id: 'send', + ok: true as const, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } +} + +const TABS_OR_SPACES: AskPrompt = { + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('useMobileNativeChatAnswerSend', () => { + let renderer: ReactTestRenderer | null = null + let answerSend: AnswerSend | null = null + let mountedClient: RpcClient | null = null + let mountedOnSendError: ((message: string) => void) | null = null + let mountedAgent: AgentType = 'claude' + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + answerSend = null + mountedClient = null + mountedOnSendError = null + mountedAgent = 'claude' + vi.useRealTimers() + }) + + function Harness({ enabled }: { enabled: boolean }): null { + answerSend = useMobileNativeChatAnswerSend({ + client: mountedClient, + enabled, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef: { current: mountedAgent }, + sessionId: 'session', + streamIdentity: 'host\0worktree\0tab\0session', + onSendError: mountedOnSendError! + }) + return null + } + + async function mount( + client: RpcClient, + onSendError: (message: string) => void, + agent: AgentType = 'claude' + ): Promise { + mountedClient = client + mountedOnSendError = onSendError + mountedAgent = agent + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function setEnabled(enabled: boolean): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { enabled })) + }) + } + + it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + }) + + it('multi-question: option numbers auto-advance, one final submit Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '1', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let result: Promise | undefined + await act(async () => { + // A newline in raw keystrokes would submit early — must collapse to space. + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '3', enter: false }), + expect.objectContaining({ text: 'zeta spaces', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('answers OpenClaude asks with Claude selector keystrokes', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('submits a non-Claude answer as pasted label text with a single Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + // Codex's question tool commits the pasted answer: label text + one Enter. + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + }) + + it('stops at the first rejected write and reports failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + }) + + it('rejects an empty selection without writing anything', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await setEnabled(false) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts new file mode 100644 index 00000000000..255f60de3a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' + +/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is + * answered by option-number keystrokes; other agents get pasted label text. + * Extracted from the session route to keep that file under its line cap and to + * own the pending-timer lifecycle in one place. */ +export type MobileNativeChatAnswerSend = { + /** Answer the current question(s) from the card's per-question selections. */ + answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + /** Drop any in-flight per-keystroke writes (call on Stop). */ + cancelPending: () => void +} + +// A free-text answer is written as raw keystrokes into Claude's "Type something" +// input (terminal.send has no paste framing), so an embedded newline would +// submit it early — collapse line breaks to spaces. +function sanitizeAskFreeText(text: string): string { + return text.replace(/[\r\n]+/g, ' ') +} + +/** + * Owns the ask-answer send sequence for the mobile native chat. Reads the live + * pane/agent through refs (the route already keeps them current) so the returned + * callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys` + * keystroke groups written one selector-step apart over the EXISTING + * `terminal.send` passthrough (raw text, no enter) — same contract the + * permission card already uses, so old runtimes replay them verbatim (no new + * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so + * a detached chain can never write PTY bytes to a stale pane. + */ +export function useMobileNativeChatAnswerSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): MobileNativeChatAnswerSend { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + sessionId, + streamIdentity, + onSendError + } = args + const generationRef = useRef(0) + const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) + activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + const delaysRef = useRef< + Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> + >(new Set()) + + const cancelPending = useCallback(() => { + generationRef.current += 1 + for (const delay of delaysRef.current) { + clearTimeout(delay.timer) + delay.resolve(false) + } + delaysRef.current.clear() + }, []) + + // Cancel pending writes on unmount and whenever the chat session swaps. + useEffect(() => { + if (!enabled) { + cancelPending() + } + return cancelPending + }, [client, enabled, sessionId, streamIdentity, cancelPending]) + + const answerAsk = useCallback( + async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Answer not sent (disconnected)') + return false + } + if (!hasAskAnswer(prompt, selections)) { + return false + } + // A new answer supersedes any still-pending keystroke writes. + cancelPending() + const generation = generationRef.current + const sendTerminal = (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { + return Promise.resolve(false) + } + return sendMobileNativeChatMessage({ + client, + terminal: handle, + text: body, + enter, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + } + const wait = (ms: number): Promise => + new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + const fail = (): false => { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + // Non-Claude question tools commit a pasted answer, so send the label text + // with one Enter. Claude's arrow-navigate selector ignores pasted labels + // (STA-1860): drive it by option-number keystrokes instead, one group per + // selector step so each renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + } + const groups = buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + } + return groups.length > 0 + }, + [ + agentRef, + enabled, + cancelPending, + client, + deviceTokenRef, + handleRef, + onSendError, + sessionId, + streamIdentity + ] + ) + + return { answerAsk, cancelPending } +} diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts new file mode 100644 index 00000000000..8c81f0255c2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' + +describe('useMobileNativeChatAskDismiss', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ prompt }: { prompt: AskPrompt }): null { + state = useMobileNativeChatAskDismiss(prompt) + return null + } + + const first: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'old second', multiSelect: false, options: [] } + ] + } + const replacement: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'new second', multiSelect: false, options: [] } + ] + } + + it('shows a structurally different replacement without an intervening null', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { prompt: first })) + }) + } finally { + consoleSpy.mockRestore() + } + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + expect(state?.showAsk).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts new file mode 100644 index 00000000000..8f7289f0753 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type { AskPrompt } from './mobile-native-chat-ask' + +/** Track the answered-ask key so the lingering live status doesn't re-show the + * same card. The agent emits a post-tool event with the same prompt right after + * an answer, so the card is hidden until a genuinely different question arrives. */ +export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + askKey: string | null + showAsk: boolean + dismissAsk: () => void +} { + const askKey = ask ? JSON.stringify(ask.questions) : null + const [dismissedAskKey, setDismissedAskKey] = useState(null) + // Once the prompt clears (agent moved on), forget the dismissal so a later + // question — even an identical one — shows again instead of staying hidden. + const askPresent = ask != null + useEffect(() => { + if (!askPresent) { + setDismissedAskKey(null) + } + }, [askPresent]) + const showAsk = askPresent && askKey !== dismissedAskKey + const dismissAsk = (): void => setDismissedAskKey(askKey) + + return { askKey, showAsk, dismissAsk } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts new file mode 100644 index 00000000000..cca9ae3f923 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import type { RpcClient } from '../transport/rpc-client' +import { + parseAskFromStatus, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { detectAgentPermission } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' +import { openMobileNativeChatFile } from './mobile-native-chat-open-file' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 + +export type MobileNativeChatController = { + chatTabIds: Set + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: Array<{ id: string; text: string }> + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + nativeChatAsk: ReturnType + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatSend: (text: string) => Promise +} + +/** Owns mobile native-chat state and teardown outside the already dense session + * route. The route remains responsible only for choosing and rendering the view. */ +export function useMobileNativeChatController(args: { + client: RpcClient | null + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + deviceTokenRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean + nativeChatInputLeaseReady: boolean + onSendError: (message: string) => void +}): MobileNativeChatController { + const { + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError + } = args + const [chatTabIds, setChatTabIds] = useState>(new Set()) + const chatTabIdsToggledRef = useRef(false) + + useEffect(() => { + let active = true + // Re-arm per host/worktree so the fresh load can seed the new selection. + chatTabIdsToggledRef.current = false + void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { + // Why: a toggle before this load resolves is authoritative; the load must + // not revert the user's in-memory choice back to the persisted state. + if (active && !chatTabIdsToggledRef.current) { + // Why: route reuse must clear the prior worktree's selection even when + // the newly loaded worktree has no saved chat tabs. + setChatTabIds(new Set(ids)) + } + }) + return () => { + active = false + } + }, [hostId, worktreeId]) + + const toggleTabChatView = useCallback( + (tabId: string) => { + chatTabIdsToggledRef.current = true + setChatTabIds((previous) => { + const next = new Set(previous) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + void saveNativeChatTabIds(hostId, worktreeId, [...next]) + return next + }) + }, + [hostId, worktreeId] + ) + + const activeChatResolution = + activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + showNativeChatRef.current = showNativeChat + const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) + activeChatAgentRef.current = activeChatResolution?.agent ?? null + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + + const nativeChatSession = useMobileNativeChatSession({ + client, + agent: activeChatResolution?.agent ?? null, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }) + const { + composerText: chatComposerText, + setComposerText: setChatComposerText, + pending: chatPending, + captureSendOrigin, + acceptSend + } = useMobileNativeChatDrafts({ + hostId, + worktreeId, + tabId: activeSessionTabId, + sessionId: activeChatSessionId, + messages: nativeChatSession.messages + }) + + const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null + const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Throttle the streaming bubble: OpenCode emits a status frame per streamed + // part, and each one re-renders and re-parses the whole accumulated markdown. + const nativeChatStreamingText = useThrottledLatestValue( + nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + NATIVE_CHAT_STREAM_THROTTLE_MS + ) + const { + permission: nativeChatPermission, + question: nativeChatQuestion, + ask: nativeChatAsk + } = useMobileNativeChatPrompts({ + enabled: activeChatResolution != null, + status: nativeChatStatus, + messages: nativeChatSession.messages + }) + + const handleNativeChatOpenFile = useCallback( + (pathText: string) => { + if (!client) { + return + } + void openMobileNativeChatFile({ + client, + worktreeId, + pathText, + terminal: activeHandleRef.current + }) + }, + [activeHandleRef, client, worktreeId] + ) + + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = + useMobileNativeChatAnswerSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, + streamIdentity, + onSendError + }) + + const handleNativeChatCancelAsk = useCallback(async (): Promise => { + const handle = activeHandleRef.current + if (!client || !handle || !nativeChatInputLeaseReady) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelNativeChatAnswer() + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Cancel not sent') + } + return accepted + }, [ + activeHandleRef, + cancelNativeChatAnswer, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ]) + + const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + onSendError + }) + + const handleNativeChatStop = useMobileNativeChatStop({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + streamIdentity, + cancelPending: cancelNativeChatAnswer, + onSendError + }) + + const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ + client, + worktreeId + }) + + const handleNativeChatSend = useCallback( + async (text: string): Promise => { + const handle = activeHandleRef.current + const origin = captureSendOrigin(text) + if (!client || !handle || !origin || !nativeChatInputLeaseReady) { + onSendError('Message not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Message not sent') + return false + } + acceptSend(origin, text) + return true + }, + [ + acceptSend, + activeHandleRef, + captureSendOrigin, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ] + ) + + return { + chatTabIds, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + nativeChatAgent: activeChatResolution?.agent ?? null, + chatComposerText, + setChatComposerText, + chatPending, + nativeChatSession, + nativeChatAgentWorking, + nativeChatStreamingText, + nativeChatPermission, + nativeChatQuestion, + nativeChatAsk, + handleNativeChatOpenFile, + handleNativeChatAnswerAsk, + handleNativeChatCancelAsk, + handleNativeChatRespondPermission, + handleNativeChatStop, + nativeChatFilePaths, + loadNativeChatFiles, + handleNativeChatSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts new file mode 100644 index 00000000000..1505c9d1dc4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -0,0 +1,192 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function assistantTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('useMobileNativeChatDrafts', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [] + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function switchTo(tabId: string): Promise { + await act(async () => renderer?.update(createElement(Harness, { tabId }))) + } + + it('keeps drafts and accepted pending messages on their originating tabs', async () => { + await mount('a') + act(() => state?.setComposerText('from a')) + const originA = state?.captureSendOrigin('from a') + expect(originA).not.toBeNull() + + await switchTo('b') + act(() => state?.setComposerText('from b')) + act(() => { + if (originA) { + state?.acceptSend(originA, 'from a') + } + }) + expect(state?.composerText).toBe('from b') + expect(state?.pending).toEqual([]) + + await switchTo('a') + expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.text)).toEqual(['from a']) + }) + + it('clears one pending per landed message so duplicate sends are not all dropped', async () => { + await mount('a') + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + }) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + }) + + it('does not reconcile a repeated send against an older identical turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [ + userTextMessage('old', 'ping'), + assistantTextMessage('other', 'working'), + userTextMessage('new', 'ping') + ] + }) + ) + ) + expect(state?.pending).toEqual([]) + }) + + it('does not erase newer edits when an older send settles', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => state?.setComposerText('new edit')) + act(() => { + if (origin) { + state?.acceptSend(origin, 'submitted') + } + }) + + expect(state?.composerText).toBe('new edit') + }) + + it('accepts and clears the first send before a provider session id exists', async () => { + await mount('a') + await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) + act(() => state?.setComposerText('start the session')) + + const origin = state?.captureSendOrigin('start the session') + expect(origin).toMatchObject({ pendingKey: null }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'start the session') + } + }) + + expect(state?.composerText).toBe('') + expect(state?.pending).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts new file mode 100644 index 00000000000..ddd730e227c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number +} +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number +} + +const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] + +function normalizedUserText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + return text || null +} + +function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number { + let count = 0 + for (const message of messages) { + if (normalizedUserText(message) === text) { + count++ + } + } + return count +} + +export function useMobileNativeChatDrafts(args: { + hostId: string + worktreeId: string + tabId: string | null + sessionId: string | null + messages: readonly NativeChatMessage[] +}): { + composerText: string + setComposerText: Dispatch> + pending: MobileNativeChatPendingMessage[] + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +} { + const { hostId, worktreeId, tabId, sessionId, messages } = args + const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null + const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null + const [drafts, setDrafts] = useState>({}) + const [pendingBySession, setPendingBySession] = useState< + Record + >({}) + const pendingCounterRef = useRef(0) + const messagesRef = useRef(messages) + messagesRef.current = messages + + const setComposerText: Dispatch> = useCallback( + (value) => { + if (!draftKey) { + return + } + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? previous : { ...previous, [draftKey]: next } + }) + }, + [draftKey] + ) + + const captureSendOrigin = useCallback( + (text: string) => { + if (!draftKey) { + return null + } + const normalizedText = text.trim() + return { + draftKey, + pendingKey, + normalizedText, + baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + } + }, + [draftKey, pendingKey] + ) + + const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { + // Why: an RPC may settle after a tab switch; mutate only the tab that + // originated the send, without erasing edits typed after it began. + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + // Why: the first prompt can be sent before the provider reports a session + // id; clear its draft, but wait for an id before keying an optimistic echo. + if (!origin.pendingKey) { + 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 + const pending = { + id: `pending-${pendingCounterRef.current}`, + text, + expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1 + } + return { ...previous, [pendingKey]: [...current, pending] } + }) + }, []) + + const pending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + useEffect(() => { + if (!pendingKey || pending.length === 0) { + return + } + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? [] + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + // Why: compare against the count captured before send; historical equal + // turns cannot clear a new echo, while duplicates land one occurrence each. + const next = current.filter( + (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence + ) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [pendingKey]: next } + } + const remaining = { ...previous } + delete remaining[pendingKey] + return remaining + }) + }, [messages, pending, pendingKey]) + + return { + composerText: draftKey ? (drafts[draftKey] ?? '') : '', + setComposerText, + pending, + captureSendOrigin, + acceptSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts new file mode 100644 index 00000000000..e0500019844 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +type SearchState = ReturnType + +function rpcSuccess(files: string[]): Awaited> { + return { + id: 'files', + ok: true, + result: { files: files.map((relativePath) => ({ relativePath })) }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('useMobileNativeChatFileSearch', () => { + let renderer: ReactTestRenderer | null = null + let state: SearchState | null = null + + async function mount(client: RpcClient): Promise { + function Harness(): null { + state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) + return null + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + }) + } finally { + restore() + } + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('coalesces rapid queries and retains only the bounded host result', async () => { + const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts'])) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => { + state?.loadNativeChatFiles('a') + state?.loadNativeChatFiles('app') + }) + await act(async () => vi.advanceTimersByTimeAsync(119)) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', { + worktree: 'id:wt-1', + query: 'app', + limit: 16 + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts']) + }) + + it('loads the legacy inventory once when an older host lacks searchPaths', async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(['src/apple.ts', 'docs/readme.md']) + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) + + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => { + const sendRequest = vi.fn(async (_method: string, params: { query: string }) => + rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts']) + ) + await mount({ sendRequest } as unknown as RpcClient) + + // Populate the cache for 'app'. + act(() => state?.loadNativeChatFiles('app')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'. + act(() => { + state?.loadNativeChatFiles('beta') + state?.loadNativeChatFiles('app') + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // The cancelled 'beta' request must never fire and overwrite the cached result. + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + expect( + sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta') + ).toHaveLength(0) + }) + + it('coalesces overlapping legacy inventory requests on a slow host', async () => { + let resolveList: (value: Awaited>) => void = () => {} + const listResponse = new Promise>>((resolve) => { + resolveList = resolve + }) + const sendRequest = vi.fn((method: string) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return listResponse + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + + await act(async () => { + resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md'])) + await Promise.resolve() + await Promise.resolve() + }) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts new file mode 100644 index 00000000000..53df98e3ee0 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { rankSuggestions } from './mobile-native-chat-autocomplete' + +const FILE_SEARCH_DEBOUNCE_MS = 120 +const FILE_SEARCH_RESULT_LIMIT = 16 +const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 + +function extractPaths(result: unknown): string[] { + const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) +} + +/** Debounces current-host path searches, bounds the mobile result/cache, and + * falls back to the legacy one-time full list when paired to an older host. */ +export function useMobileNativeChatFileSearch(args: { + client: RpcClient | null + worktreeId: string +}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } { + const { client, worktreeId } = args + const [nativeChatFilePaths, setNativeChatFilePaths] = useState([]) + const timerRef = useRef | null>(null) + const sequenceRef = useRef(0) + const generationRef = useRef(0) + const queryCacheRef = useRef(new Map()) + const legacyPathsRef = useRef(null) + const legacyLoadRef = useRef | null>(null) + const searchSupportedRef = useRef(null) + + useEffect(() => { + sequenceRef.current++ + generationRef.current++ + queryCacheRef.current.clear() + legacyPathsRef.current = null + legacyLoadRef.current = null + searchSupportedRef.current = null + setNativeChatFilePaths([]) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, worktreeId]) + + const loadNativeChatFiles = useCallback( + (query: string) => { + if (!client) { + return + } + const normalizedQuery = query.trim().toLowerCase().slice(0, 256) + const cached = queryCacheRef.current.get(normalizedQuery) + if (cached) { + // Why: cancel and stale-out any in-flight debounced query so an older + // request cannot later clobber this displayed cached result. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + sequenceRef.current++ + setNativeChatFilePaths(cached) + return + } + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const sequence = ++sequenceRef.current + const generation = generationRef.current + setNativeChatFilePaths([]) + timerRef.current = setTimeout(() => { + timerRef.current = null + const applyPaths = (paths: string[]): void => { + if (sequenceRef.current !== sequence) { + return + } + queryCacheRef.current.set(normalizedQuery, paths) + while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) { + const oldest = queryCacheRef.current.keys().next().value as string | undefined + if (!oldest) { + break + } + queryCacheRef.current.delete(oldest) + } + setNativeChatFilePaths(paths) + } + const loadLegacyPaths = async (): Promise => { + if (!legacyPathsRef.current) { + if (!legacyLoadRef.current) { + const request = client + .sendRequest('files.list', { worktree: `id:${worktreeId}` }) + .then((response) => { + if (!response.ok || generationRef.current !== generation) { + return null + } + const paths = extractPaths(response.result) + legacyPathsRef.current = paths + return paths + }) + .finally(() => { + if (legacyLoadRef.current === request && !legacyPathsRef.current) { + legacyLoadRef.current = null + } + }) + // Why: older hosts expose only the full inventory RPC; queries that + // overlap its slow local/SSH read must share one request. + legacyLoadRef.current = request + } + const paths = await legacyLoadRef.current + if (!paths) { + return + } + } + const legacyPaths = legacyPathsRef.current + if (legacyPaths) { + applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + } + } + void (async () => { + if (searchSupportedRef.current === false) { + await loadLegacyPaths() + return + } + const response = await client.sendRequest('files.searchPaths', { + worktree: `id:${worktreeId}`, + query: normalizedQuery, + limit: FILE_SEARCH_RESULT_LIMIT + }) + if (response.ok) { + searchSupportedRef.current = true + applyPaths(extractPaths(response.result)) + return + } + if (response.error.code === 'method_not_found') { + searchSupportedRef.current = false + await loadLegacyPaths() + } + })().catch(() => {}) + }, FILE_SEARCH_DEBOUNCE_MS) + }, + [client, worktreeId] + ) + + return { nativeChatFilePaths, loadNativeChatFiles } +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts new file mode 100644 index 00000000000..bb90327c5b9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' + +type Lease = ReturnType + +describe('useMobileNativeChatInputLease', () => { + let renderer: ReactTestRenderer | null = null + let lease: Lease | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + lease = null + }) + + function Harness({ connected }: { connected: boolean }): null { + lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected }) + return null + } + + it('unlocks only after acknowledgement and clears on disconnect', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() + + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts new file mode 100644 index 00000000000..02dfeff84a8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +export function useMobileNativeChatInputLease(args: { + activeHandle: string | null + connected: boolean +}): { + ready: boolean + readyRef: { readonly current: boolean } + lockReason: MobileNativeChatInputLockReason | null + markReady: (handle: string) => void + clear: (handle?: string) => void +} { + const [readyHandles, setReadyHandles] = useState>(new Set()) + const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) + // Why: absence of an acknowledgement proves only that setup is still pending; + // the protocol does not report evidence that another client owns the floor. + const lockReason: MobileNativeChatInputLockReason | null = !args.connected + ? 'disconnected' + : ready + ? null + : 'waiting' + const readyRef = useRef(ready) + readyRef.current = ready + useEffect(() => { + if (!args.connected) { + setReadyHandles(new Set()) + } + }, [args.connected]) + const markReady = useCallback((handle: string) => { + setReadyHandles((current) => new Set(current).add(handle)) + }, []) + const clear = useCallback((handle?: string) => { + setReadyHandles((current) => { + if (handle === undefined) { + return new Set() + } + if (!current.has(handle)) { + return current + } + const next = new Set(current) + next.delete(handle) + return next + }) + }, []) + return { + ready, + readyRef, + lockReason, + markReady, + clear + } +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts new file mode 100644 index 00000000000..3b3cdb2e8a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef, useState } from 'react' +import { Gesture } from 'react-native-gesture-handler' +import type { ComposedGesture } from 'react-native-gesture-handler' +import { clampFontScale } from './mobile-native-chat-message-text' + +/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` + * anchors the live gesture so successive pinches compound rather than reset. */ +export function useMobileNativeChatPinchGesture(): { + fontScale: number + pinchGesture: ComposedGesture +} { + const [fontScale, setFontScale] = useState(1) + const fontScaleRef = useRef(1) + fontScaleRef.current = fontScale + const pinchBase = useRef(1) + // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so + // they can touch React refs/state and clampFontScale directly — accessing those + // from the UI-thread worklet crashes the app. + // Compose the pinch with the list's native scroll as Simultaneous so a + // two-finger pinch is recognized even while the scroll view is active — + // otherwise the scroll grabs the gesture first and the pinch never fires. + const pinchGesture = useMemo( + () => + Gesture.Simultaneous( + Gesture.Native(), + Gesture.Pinch() + .runOnJS(true) + .onStart(() => { + pinchBase.current = fontScaleRef.current + }) + .onUpdate((e) => { + setFontScale(clampFontScale(pinchBase.current * e.scale)) + }) + ), + [] + ) + + return { fontScale, pinchGesture } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts new file mode 100644 index 00000000000..11a17696bfc --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' + +export type MobileNativeChatPrompts = { + permission: ReturnType + question: ReturnType + ask: ReturnType +} + +/** Derives the prompt cards shown above the composer. */ +export function useMobileNativeChatPrompts(args: { + enabled: boolean + status: AgentStatusEntry | null | undefined + messages: readonly NativeChatMessage[] +}): MobileNativeChatPrompts { + const { enabled, status, messages } = args + const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const permission = + (blocked && status + ? detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) + : null) ?? parseApprovalFromStatus(status?.interactivePrompt) + const question = + blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null + const askFromStatus = useMemo( + () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), + [status?.interactivePrompt, status?.toolName] + ) + const askFromMessages = useMemo( + () => (askFromStatus ? null : extractPendingAsk(messages)), + [askFromStatus, messages] + ) + + return { + permission, + question, + ask: enabled ? (askFromStatus ?? askFromMessages) : null + } +} diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts new file mode 100644 index 00000000000..46feaf79217 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -0,0 +1,107 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' + +describe('useMobileNativeChatReadability', () => { + let renderer: ReactTestRenderer | null = null + let readable = false + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + readable = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount(connectionId: string | null): Promise { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) + } as unknown as RpcClient + function Harness(): null { + readable = useMobileNativeChatReadability(client, 'repo::/worktree') + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('admits local and runtime-owned transcript hosts', async () => { + await mount(null) + expect(readable).toBe(true) + act(() => renderer?.unmount()) + renderer = null + + await mount('runtime-ssh-environment') + expect(readable).toBe(true) + }) + + it('fails closed for Model-A SSH transcript hosts', async () => { + await mount('model-a-ssh') + expect(readable).toBe(false) + }) + + it('fails closed immediately while a reused route resolves its new worktree', async () => { + let resolveNext: (response: unknown) => void = () => {} + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repos: [{ id: 'local-repo', connectionId: null }] } + }) + .mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve))) + } as unknown as RpcClient + function Harness({ worktreeId }: { worktreeId: string }): null { + readable = useMobileNativeChatReadability(client, worktreeId) + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() + }) + expect(readable).toBe(true) + + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } + }) + await Promise.resolve() + }) + expect(readable).toBe(false) + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts new file mode 100644 index 00000000000..e952335fe23 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' + +type RepoSummary = { id: string; connectionId?: string | null } +type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } + +export function useMobileNativeChatReadability( + client: RpcClient | null, + worktreeId: string +): boolean { + const [state, setState] = useState({ + client: null, + worktreeId: '', + readable: false + }) + useEffect(() => { + let active = true + if (!client) { + setState({ client, worktreeId, readable: false }) + return + } + void client + .sendRequest('repo.list') + .then((response) => { + if (!active) { + return + } + const repos = response.ok + ? ((response.result as { repos?: RepoSummary[] }).repos ?? []) + : [] + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repo = repos.find((candidate) => candidate.id === repoId) + setState({ + client, + worktreeId, + readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + }, [client, worktreeId]) + // Why: route reuse renders before its new effect resolves; never expose the + // previous repo's readability under a different client/worktree key. + return state.client === client && state.worktreeId === worktreeId ? state.readable : false +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts new file mode 100644 index 00000000000..584f34624f2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -0,0 +1,220 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useMobileNativeChatSession', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + let emit: (frame: unknown) => void = () => {} + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ client }: { client: RpcClient | null }): null { + state = useMobileNativeChatSession({ + client, + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + return null + } + + async function mount(client: RpcClient): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('drops an older-page response captured before transcript replacement', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + await act(async () => { + emit({ + type: 'replacement', + messages: [message('replacement')], + hasMore: false, + beforeOffset: 0 + }) + }) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 } + }) + await Promise.resolve() + }) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement']) + expect(state?.loadingEarlier).toBe(false) + }) + + it('drops an older-page response after the client source disappears', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => renderer?.update(createElement(Harness, { client: null }))) + await act(async () => { + resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } }) + await Promise.resolve() + }) + + expect(state?.messages).toEqual([]) + expect(state?.status).toBe('idle') + expect(state?.loadingEarlier).toBe(false) + }) + + it.each(['replacement', 'snapshot'] as const)( + 'can page again after an authoritative %s resets a maxed-out read window', + async (frameType) => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 50 } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + for (let page = 0; page < 33; page += 1) { + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + } + const requestsAtCap = sendRequest.mock.calls.length + + await act(async () => + emit({ + type: frameType, + messages: [message('authoritative')], + hasMore: true, + beforeOffset: 500 + }) + ) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 60, + beforeOffset: 500 + }) + } + ) + + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { + let resolveCursorPage: (response: unknown) => void = () => {} + const sendRequest = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve))) + .mockResolvedValueOnce({ + ok: true, + result: { messages: [message('fresh-growing-tail')], hasMore: false } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => emit({ type: 'appended', messages: [message('live-trim')] })) + await act(async () => { + resolveCursorPage({ + ok: true, + result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 } + }) + await Promise.resolve() + }) + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page') + + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts new file mode 100644 index 00000000000..2d381fdd602 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' +import type { RpcClient } from '../transport/rpc-client' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { + applyMobileNativeChatStreamFrame, + type MobileNativeChatStreamFrame +} from './mobile-native-chat-stream-frame' + +export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' + +export type MobileNativeChatSession = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** True when an older page may exist (the last read filled the window). */ + hasMore: boolean + /** Whether an older-history page is currently loading. */ + loadingEarlier: boolean + /** Grow the window to page in older history. */ + loadEarlier: () => void +} + +// Small first page for a fast first paint; grows by a page as the user scrolls. +const INITIAL_LIMIT = 40 +const PAGE = 60 +const MAX_MESSAGES = 2000 + +type ReadSessionResult = + | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } + | { error: string } +/** Subscribe to an agent's native-chat transcript over the paired connection. + * Reads a small recent window for a fast first paint, tails it for live turns, + * and pages in older history on demand. Read results replace the list (they are + * an ordered tail); live appends merge by id so order stays stable. */ +export function useMobileNativeChatSession(args: { + client: RpcClient | null + agent: string | null + sessionId: string | null + transcriptPath: string | null +}): MobileNativeChatSession { + const { client, agent, sessionId, transcriptPath } = args + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [loadingEarlier, setLoadingEarlier] = useState(false) + const loadingEarlierRef = useRef(false) + const beforeOffsetRef = useRef(null) + // Stateful id-dedup merger: caches the id→index map so each live append frame + // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the + // base (read / loadEarlier ordered tails); `applyAppend` folds live frames in. + const mergerRef = useRef(createNativeChatMerger()) + const limitRef = useRef(INITIAL_LIMIT) + // Tracks the live session so a late loadEarlier resolve can detect a swap. + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const streamGenerationRef = useRef(0) + + // Replace the base list (read results are an ordered tail). Resets the merger + // cache so the index is rebuilt once over the new base. + const setList = useCallback((next: readonly NativeChatMessage[]) => { + replaceList(mergerRef.current, next) + setMessages(mergerRef.current.list) + }, []) + + useEffect(() => { + let cancelled = false + // Why: disconnect/agent/session loss must invalidate a page request before + // the early idle/waiting return can clear the visible source. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + setList([]) + setError(undefined) + setHasMore(false) + beforeOffsetRef.current = null + if (!client || !agent) { + setStatus('idle') + return + } + if (!sessionId) { + setStatus('waiting-session') + return + } + + setStatus('loading') + + const unsubscribe = client.subscribe( + 'nativeChat.subscribe', + { + agent, + sessionId, + limit: limitRef.current, + subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + ...(transcriptPath ? { transcriptPath } : {}) + }, + (raw) => { + if (cancelled) { + return + } + const frame = raw as MobileNativeChatStreamFrame + if (frame.type === 'replacement' || frame.type === 'snapshot') { + // Why: replacement and reconnect snapshots are authoritative windows; + // stale page limits/results must not constrain the fresh generation. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + const replaceSnapshot = frame.type === 'snapshot' + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setStatus('error') + setError(applied.error) + return + } + setMessages(applied.messages) + if (applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setStatus('ready') + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, [client, agent, sessionId, transcriptPath, setList]) + + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + const requestGeneration = streamGenerationRef.current + const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES) + const pageLimit = nextLimit - limitRef.current + if (pageLimit <= 0) { + setHasMore(false) + return + } + const beforeOffset = beforeOffsetRef.current + loadingEarlierRef.current = true + setLoadingEarlier(true) + void (async () => { + try { + const response = await client.sendRequest('nativeChat.readSession', { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }) + if (!response.ok) { + return + } + const result = response.result as ReadSessionResult + if ('error' in result) { + return + } + // Drop a stale resolve from a session that swapped underneath us. + if ( + sessionIdRef.current !== requestSessionId || + streamGenerationRef.current !== requestGeneration + ) { + return + } + limitRef.current = nextLimit + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + setHasMore( + nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + ) + } else { + // Older runtimes ignore the cursor and return the growing tail. + setList(result.messages) + setHasMore(result.messages.length >= nextLimit) + } + } finally { + // A late page from a prior tab must not unlock the current tab's request. + if ( + sessionIdRef.current === requestSessionId && + streamGenerationRef.current === requestGeneration + ) { + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + } + })() + }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + + return { messages, status, error, hasMore, loadingEarlier, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts new file mode 100644 index 00000000000..27385f8d33e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' + +describe('useMobileNativeChatStop', () => { + let renderer: ReactTestRenderer | null = null + let stop: (() => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendRequest.mockReset().mockResolvedValue({ ok: true }) + onSendError.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + stop = null + vi.useRealTimers() + }) + + function Harness({ + enabled, + streamIdentity + }: { + enabled: boolean + streamIdentity: string + }): null { + stop = useMobileNativeChatStop({ + client: { sendRequest } as unknown as RpcClient, + enabled, + handleRef: { current: 'terminal-1' }, + deviceTokenRef: { current: 'mobile-1' }, + streamIdentity, + cancelPending: vi.fn(), + onSendError + }) + return null + } + + async function render(enabled: boolean, streamIdentity: string): Promise { + await act(async () => { + const element = createElement(Harness, { enabled, streamIdentity }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it.each([ + ['the acknowledged input lease is lost', false, 'stream-1'], + ['the active stream changes', true, 'stream-2'] + ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { + await render(true, 'stream-1') + + act(() => stop?.()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await render(enabled as boolean, streamIdentity as string) + await act(async () => vi.runAllTimersAsync()) + + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('handles a rejected Escape without leaking an unhandled rejection', async () => { + sendRequest.mockRejectedValue(new Error('disconnected')) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts new file mode 100644 index 00000000000..c6d36ea3436 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' + +export function useMobileNativeChatStop(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + streamIdentity: string + cancelPending: () => void + onSendError: (message: string) => void +}): () => void { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = + args + const timerRef = useRef | null>(null) + const activeRouteRef = useRef({ client, enabled, streamIdentity }) + activeRouteRef.current = { client, enabled, streamIdentity } + useEffect(() => { + if (!enabled && timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, enabled, streamIdentity]) + return useCallback(() => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Stop not sent (terminal not ready)') + return + } + cancelPending() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const stopStreamIdentity = streamIdentity + let failureReported = false + const sendEscape = (): void => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.streamIdentity !== stopStreamIdentity || + handleRef.current !== handle + ) { + return + } + void client + .sendRequest('terminal.send', { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Why: disconnect can race either fire-and-forget Escape; surface one + // failure instead of leaking an unhandled RPC rejection. + if (!failureReported) { + failureReported = true + onSendError('Stop not sent') + } + }) + } + sendEscape() + // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. + timerRef.current = setTimeout(() => { + timerRef.current = null + sendEscape() + }, 80) + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) +} diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..92320b9ff86 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,115 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream' + +describe('useMobileNativeChatTerminalStream', () => { + let renderer: ReactTestRenderer | null = null + let harnessRenderCount = 0 + const subscriptionsRef = { current: new Map void>() } + const subscribingRef = { current: new Set() } + const webReadyRef = { current: new Set(['terminal-1']) } + const initializedRef = { current: new Set(['terminal-1']) } + const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) + const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + subscriptionsRef.current = new Map([['terminal-1', () => {}]]) + subscribingRef.current = new Set() + webReadyRef.current = new Set(['terminal-1']) + initializedRef.current = new Set(['terminal-1']) + harnessRenderCount = 0 + subscribe.mockClear() + unsubscribe.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + harnessRenderCount += 1 + notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle: 'terminal-1', + activeTabType: 'terminal', + subscriptionsRef, + subscribingRef, + webReadyRef, + initializedRef, + subscribe, + unsubscribe + }) + return null + } + + it('replaces output with a lease-only stream while covered, then restores output', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() + + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..c45980ae6e3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' + +/** Pauses the active terminal stream while native chat covers its mounted WebView, + * then resumes from a fresh scrollback snapshot when terminal view returns. */ +export function useMobileNativeChatTerminalStream(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + subscriptionsRef: MutableRefObject void>> + subscribingRef: MutableRefObject> + webReadyRef: MutableRefObject> + initializedRef: MutableRefObject> + subscribe: (handle: string) => void + unsubscribe: (handle: string) => void +}): (handle: string, wasAlreadyReady: boolean) => void { + const coveredHandleRef = useRef(null) + const [webReadyRevision, setWebReadyRevision] = useState(0) + const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + if (!wasAlreadyReady && coveredHandleRef.current === handle) { + setWebReadyRevision((revision) => revision + 1) + } + }, []) + useEffect(() => { + const handle = args.activeHandle + if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + coveredHandleRef.current = null + } + const streamActive = + handle != null && + (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const action = resolveMobileNativeChatTerminalStreamAction({ + showNativeChat: args.showNativeChat, + activeHandle: handle, + activeTabType: args.activeTabType, + streamActive, + streamCovered: coveredHandleRef.current === handle, + webViewReady: handle != null && args.webReadyRef.current.has(handle) + }) + if (!handle || action === 'none') { + return + } + if (action === 'pause') { + coveredHandleRef.current = handle + // Why: returning to terminal must accept the fresh scrollback snapshot; + // the stream was paused while chat covered output that xterm never saw. + args.initializedRef.current.delete(handle) + // Why: covered chat needs the input-floor lease without paying to stream + // duplicate PTY output. Replace any view stream with a lease-only stream. + if (streamActive) { + args.unsubscribe(handle) + } + args.subscribe(handle) + return + } + if (coveredHandleRef.current === handle) { + args.unsubscribe(handle) + coveredHandleRef.current = null + } + args.subscribe(handle) + }, [ + args.activeHandle, + args.activeTabType, + args.initializedRef, + args.showNativeChat, + args.subscribe, + args.subscribingRef, + args.subscriptionsRef, + args.unsubscribe, + args.webReadyRef, + webReadyRevision + ]) + return notifyWebReady +} diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts new file mode 100644 index 00000000000..bdea29c7459 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +describe('useThrottledLatestValue', () => { + let renderer: ReactTestRenderer | null = null + let latest: string | undefined + let consoleSpy: MockInstance + + function Harness({ value }: { value: string | undefined }): null { + latest = useThrottledLatestValue(value, 50) + return null + } + + function render(value: string | undefined): void { + act(() => { + renderer = create(createElement(Harness, { value })) + }) + } + + function update(value: string | undefined): void { + act(() => renderer?.update(createElement(Harness, { value }))) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + latest = undefined + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + consoleSpy.mockRestore() + }) + + it('emits the first frame immediately', () => { + render('a') + expect(latest).toBe('a') + }) + + it('holds rapid updates but eventually emits the latest value', () => { + render('a') + update('ab') + update('abc') + expect(latest).toBe('a') + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBe('abc') + }) + + it('clears immediately and drops the trailing emit when the value goes undefined', () => { + render('a') + update('ab') + expect(latest).toBe('a') + update(undefined) + expect(latest).toBeUndefined() + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-throttled-latest-value.ts b/mobile/src/session/use-throttled-latest-value.ts new file mode 100644 index 00000000000..652b1e79119 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' + +/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs` + * while always surfacing the latest value. OpenCode publishes a streaming + * assistant frame per part; unthrottled, each re-parses the whole bubble. */ +export function useThrottledLatestValue(value: T, intervalMs: number): T { + const [throttled, setThrottled] = useState(value) + const valueRef = useRef(value) + valueRef.current = value + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (value == null) { + // Turn ended: drop any trailing emit and reset so the next stream's first + // frame shows at once instead of the stale bubble lingering. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastEmitRef.current = 0 + setThrottled(value) + return + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= intervalMs) { + lastEmitRef.current = Date.now() + setThrottled(value) + return + } + if (timerRef.current) { + return + } + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = Date.now() + setThrottled(valueRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, + [] + ) + + return throttled +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index f8e07fb4d98..881391db0bd 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,6 +8,7 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -17,6 +18,7 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('native chat tab preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('loads and saves tab ids under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ + 'tab-1', + 'tab-2' + ]) + expect(AsyncStorage.getItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' + ) + + await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['tab-2']) + ) + }) +}) + describe('push notification preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..a5af9bf350f 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +/** Tab ids currently showing native chat, scoped to the paired host and worktree + * so colliding remote ids cannot activate a transcript watcher on another host. */ +export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { + try { + const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +export async function saveNativeChatTabIds( + hostId: string, + worktreeId: string, + ids: string[] +): Promise { + try { + await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) + } catch { + // Best-effort persistence; a failed write just forgets the view choice. + } +} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 76b14415a32..dbf09b745b9 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -18,6 +18,9 @@ export const colors = { surfaceBright: '#f5f5f5', accentBlue: '#3b82f6', + // Text/icon color on a filled accent (accentBlue) button, where the muted + // textPrimary would lack contrast against the saturated fill. + onAccent: '#ffffff', statusGreen: '#22c55e', statusAmber: '#f59e0b', diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 9afc4a1fd0c..471bc3c4a4e 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -1,3 +1,5 @@ +import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe' + type TerminalStreamParams = { terminal?: unknown } @@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport( } } +/** Build the unsubscribe RPC for a streaming method that needs the host told to + * tear down (session tabs, native chat), or null when none is required. Keeps + * the per-method echo logic out of the rpc-client teardown closure. */ +export function buildStreamUnsubscribe( + method: string | undefined, + params: unknown +): { method: string; params: Record } | null { + if (!params || typeof params !== 'object') { + return null + } + if (method === 'session.tabs.subscribe') { + const worktree = (params as { worktree?: unknown }).worktree + return typeof worktree === 'string' + ? { method: 'session.tabs.unsubscribe', params: { worktree } } + : null + } + if (method === 'nativeChat.subscribe') { + const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId + if (typeof subscriptionId === 'string') { + return { method: 'nativeChat.unsubscribe', params: { subscriptionId } } + } + // Backward compatibility for callers that predate explicit cleanup tokens. + const agent = (params as { agent?: unknown }).agent + const sessionId = (params as { sessionId?: unknown }).sessionId + return typeof agent === 'string' && typeof sessionId === 'string' + ? buildNativeChatUnsubscribe(agent, sessionId) + : null + } + return null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 014bea955cd..5420ba679a6 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -23,6 +23,7 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' @@ -1171,18 +1172,11 @@ export function connect( params: unsubscribeParams }) } - } else if ( - stream?.method === 'session.tabs.subscribe' && - stream.params && - typeof stream.params === 'object' && - typeof (stream.params as { worktree?: unknown }).worktree === 'string' - ) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'session.tabs.unsubscribe', - params: { worktree: (stream.params as { worktree: string }).worktree } - }) + } else { + const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsub) { + sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) + } } removeStreamListener(id) } diff --git a/package.json b/package.json index 58aa9067519..c7e41d8ce0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.143-rc.0", + "version": "1.4.144-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/tray/orca-menu-barTemplate.png b/resources/tray/orca-menu-barTemplate.png new file mode 100644 index 00000000000..b98b90f2fda Binary files /dev/null and b/resources/tray/orca-menu-barTemplate.png differ diff --git a/resources/tray/orca-menu-barTemplate@2x.png b/resources/tray/orca-menu-barTemplate@2x.png new file mode 100644 index 00000000000..fffb571fe76 Binary files /dev/null and b/resources/tray/orca-menu-barTemplate@2x.png differ diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts new file mode 100644 index 00000000000..1791baa82cd --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -0,0 +1,168 @@ +import { extname } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const REMOTE_DISCOVERY_CONCURRENCY = 8 + +export async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + 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 paths = partition ? partition.sessionFilePaths : walked + const files = await mapDiscoveryConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + Boolean(args.source.fixedChildFileSegments) + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) +} + +async function listRemoteFixedChildFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + let entries + try { + entries = await context.provider.readDir(source.rootDir) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) + return [] + } + const segments = source.fixedChildFileSegments ?? [] + // Why: Antigravity's transcript path is fixed. Constructing it avoids three + // serialized SSH readDir round trips for every conversation directory. + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[], + dirPath = source.rootDir, + depth = 0 +): Promise { + let entries + try { + entries = await context.provider.readDir(dirPath) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + if ( + entry.isDirectory && + !entry.isSymlink && + (source.directoryPredicate?.(entry.name, depth) ?? true) + ) { + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + missingIsExpected: boolean +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + if (!missingIsExpected || !isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + } + return null + } +} + +function recordRemoteDirectoryIssue( + source: RemoteSessionSource, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + path: string, + err: unknown +): void { + if (!isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + } +} + +function isMissingRemotePathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +async function mapDiscoveryConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..c33a0ae7701 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' @@ -49,6 +51,7 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. collectSubagentSiblingCounts: true }, + remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', remoteHome, @@ -108,6 +111,30 @@ export function remoteSessionSources( ] } +function remoteAntigravitySource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') + const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + return { + agent: 'antigravity', + rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], + parse: async (file, content, context) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context) + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } + } +} + function source( agent: AiVaultAgent, remoteHome: string, @@ -115,13 +142,15 @@ function source( segments: readonly string[], extensions: readonly string[], parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean + filePredicate?: (path: string) => boolean, + directoryPredicate?: (name: string, depth: number) => boolean ): RemoteSessionSource { return { agent, rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), extensions, filePredicate, + directoryPredicate, parse: (file, content, context) => Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..4da4bd3f4a6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,12 +3,14 @@ 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 { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform titleCaches: Map>> + antigravityWorkspaceResolver: AntigravityWorkspaceResolver } export type RemoteParserOptions = { @@ -21,6 +23,10 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Depth 0 denotes a direct child of rootDir. + 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 diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..4f9cbd73bb0 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -6,13 +6,29 @@ 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()) { @@ -45,6 +61,10 @@ class MemoryRemoteProvider implements IFilesystemProvider { } 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}`) @@ -201,6 +221,184 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` + provider.addFile( + `${logsDir}/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Fix remote Antigravity history' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-07-15T11:39:12Z', + content: 'Done' + } + ]), + 50 + ) + provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) + provider.addFile( + `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, + 'not a transcript', + 52 + ) + provider.addFile( + '/home/ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Fix remote Antigravity history', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/home/ada/project' + } + ]), + 53 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/project'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'antigravity', + sessionId, + title: 'Fix remote Antigravity history', + cwd: '/home/ada/project', + messageCount: 2, + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: `${logsDir}/transcript.jsonl` + }) + expect( + provider.readDirPaths.filter((path) => + path.startsWith('/home/ada/.gemini/antigravity-cli/brain') + ) + ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) + }) + + it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + for (let index = 0; index < 40; index++) { + provider.addFile( + `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, + content: `Remote session ${index}` + } + ]), + 100 + index + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(40) + expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) + }) + + it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) + const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` + provider.addFile(deniedTranscript, 'unreadable', 2) + provider.failStat( + deniedTranscript, + new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'antigravity', + path: deniedTranscript, + message: expect.stringContaining('EACCES') + }) + ]) + }) + + it('reports non-missing fixed and recursive remote directory failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + const claudeProjectDir = '/home/ada/.claude/projects/repo' + provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) + provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) + provider.failReadDir( + claudeProjectDir, + new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: 'antigravity', + path: brainDir, + message: expect.stringContaining('EACCES') + }), + expect.objectContaining({ + agent: 'claude', + path: claudeProjectDir, + message: expect.stringContaining('ECONNRESET') + }) + ]) + ) + expect(result.issues).toHaveLength(2) + }) + + it('keeps missing optional remote directories silent', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([]) + }) + it('excludes Claude subagent transcripts from remote scans', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -357,6 +555,49 @@ describe('scanRemoteAiVaultSessions', () => { ) }) + it('loads Antigravity workspace history with Windows remote paths', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' + provider.addFile( + `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Windows Antigravity title' + } + ]), + 30 + ) + provider.addFile( + 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Windows Antigravity title', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: 'C:/repo/app' + } + ]), + 31 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + cwd: 'C:/repo/app', + executionHostPlatform: 'win32' + }) + }) + it('continues past skipped candidates to fill the remote scan limit', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..c8e07929b61 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,25 +1,18 @@ -import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import type { FileWithMtime } from './session-scanner-types' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +32,15 @@ export async function scanRemoteAiVaultSessions(args: { provider: args.provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, - titleCaches: new Map() + titleCaches: new Map(), + antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { + try { + const read = await args.provider.readFile(historyPath) + return read.isBinary ? null : read.content + } catch { + return null + } + }) } const candidates = ( await mapRemoteScanConcurrently( @@ -73,70 +74,6 @@ export async function scanRemoteAiVaultSessions(args: { } } -async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - const walked = await walkRemoteSessionFiles( - args.source, - args.context.provider, - args.context.hostPlatform - ) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues - ) - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - provider: IFilesystemProvider, - hostPlatform: RemoteHostPlatform, - dirPath = source.rootDir -): Promise { - let entries - try { - entries = await provider.readDir(dirPath) - } catch { - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) - if (entry.isDirectory && !entry.isSymlink) { - files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - async function parseRemoteSessionCandidates(args: { candidates: readonly RemoteSessionCandidate[] context: RemoteScannerContext @@ -254,30 +191,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 22c6cd46abc..029227d8745 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' @@ -37,6 +38,8 @@ export async function parseAgentSessionFile( return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) case 'gemini': return parseGeminiSessionFile(candidate.file, platform) + case 'antigravity': + return parseAntigravitySessionFile(candidate.file, platform) case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': diff --git a/src/main/ai-vault/session-scanner-antigravity-fixtures.ts b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts new file mode 100644 index 00000000000..cbcab494933 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts @@ -0,0 +1,35 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +export function antigravityFixture(): IncrementalAgentFixture { + const record = (source: string, type: string, content: string, createdAt: string) => + JSON.stringify({ source, type, content, created_at: createdAt }) + return { + agent: 'antigravity', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/.system_generated/logs/transcript.jsonl', + seedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity seed question', + '2026-05-01T10:00:00.000Z' + ), + record('MODEL', 'PLANNER_RESPONSE', 'antigravity seed answer', '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + record( + 'USER', + 'REQUEST', + 'antigravity follow-up', + '2026-05-01T10:01:00.000Z' + ) + ], + truncatedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity rewritten', + '2026-05-01T10:00:00.000Z' + ) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts new file mode 100644 index 00000000000..989ac7b4179 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -0,0 +1,77 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' + +const HISTORY_MATCH_WINDOW_MS = 2_000 + +type AntigravityHistoryEntry = { + timestampMs: number + workspace: string +} + +type AntigravityHistoryIndex = Map + +export type AntigravityWorkspaceResolver = { + enrich(session: AiVaultSession, historyPath: string): Promise +} + +export function createAntigravityWorkspaceResolver( + readHistory: (historyPath: string) => Promise +): AntigravityWorkspaceResolver { + const indexes = new Map>() + + return { + async enrich(session, historyPath) { + if (session.agent !== 'antigravity' || session.cwd) { + return session + } + let index = indexes.get(historyPath) + if (!index) { + index = readHistory(historyPath).then(indexAntigravityHistory) + indexes.set(historyPath, index) + } + const workspace = findAntigravityWorkspace(session, await index) + return workspace ? { ...session, cwd: workspace } : session + } + } +} + +function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex { + const index: AntigravityHistoryIndex = new Map() + for (const line of content?.split(/\r?\n/) ?? []) { + const record = parseJsonObject(line) + const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null + const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : '' + const entryTimestampMs = timestampMs(record?.timestamp) + if (!display || !workspace || !Number.isFinite(entryTimestampMs)) { + continue + } + const entries = index.get(display) ?? [] + entries.push({ timestampMs: entryTimestampMs, workspace }) + index.set(display, entries) + } + return index +} + +function findAntigravityWorkspace( + session: AiVaultSession, + index: AntigravityHistoryIndex +): string | null { + // Why: truncated titles are not prompt identities; long worker prompts often + // share the same 96-character prefix across unrelated workspaces. + if (session.title.endsWith('...')) { + return null + } + const firstTitledUserTimestamp = session.previewMessages.find( + (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title + )?.timestamp + const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt) + if (!Number.isFinite(promptTimestampMs)) { + return null + } + const matches = (index.get(session.title) ?? []).filter( + (entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS + ) + // Why: history rows have no conversation id. A unique prompt/time match is + // evidence for cwd; ambiguity must stay unknown instead of crossing projects. + return matches.length === 1 ? (matches[0]?.workspace ?? null) : null +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.test.ts b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts new file mode 100644 index 00000000000..c8d037098ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import type { FileWithMtime } from './session-scanner-types' +import { jsonLines } from './session-scanner-test-fixtures' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('Antigravity AI Vault parser', () => { + it('parses the established transcript contract without leaking metadata wrappers', async () => { + const session = await parseAntigravitySessionContent( + file( + `/home/ada/.gemini/antigravity-cli/brain/${SESSION_ID}/.system_generated/logs/transcript.jsonl` + ), + jsonLines([ + { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + status: 'DONE', + created_at: '2026-07-15T11:39:10Z', + content: + '\nFix the vault adapter\n\nignored' + }, + { + step_index: 1, + source: 'SYSTEM', + type: 'CONVERSATION_HISTORY', + created_at: '2026-07-15T11:39:11Z' + }, + { + step_index: 2, + source: 'MODEL', + type: 'PLANNER_RESPONSE', + status: 'DONE', + created_at: '2026-07-15T11:39:12Z', + content: 'The adapter is ready.' + }, + { + step_index: 3, + source: 'SYSTEM', + type: 'CHECKPOINT', + created_at: '2026-07-15T11:39:14Z', + content: 'internal checkpoint' + } + ]), + 'linux' + ) + + expect(session).toMatchObject({ + agent: 'antigravity', + sessionId: SESSION_ID, + title: 'Fix the vault adapter', + cwd: null, + model: null, + createdAt: '2026-07-15T11:39:10.000Z', + updatedAt: '2026-07-15T11:39:14.000Z', + messageCount: 2, + resumeCommand: `agy --conversation '${SESSION_ID}'` + }) + expect(session?.previewMessages).toEqual([ + { + role: 'user', + text: 'Fix the vault adapter', + timestamp: '2026-07-15T11:39:10.000Z' + }, + { + role: 'assistant', + text: 'The adapter is ready.', + timestamp: '2026-07-15T11:39:12.000Z' + } + ]) + }) + + it('derives the conversation id from Windows paths and quotes the resume command', async () => { + const session = await parseAntigravitySessionContent( + file( + `C:\\Users\\Ada\\.gemini\\antigravity-cli\\brain\\${SESSION_ID}\\.system_generated\\logs\\transcript.jsonl` + ), + jsonLines([ + { + source: 'USER', + type: 'REQUEST', + created_at: '2026-07-15T11:39:10Z', + content: 'Windows prompt' + } + ]), + 'win32' + ) + + expect(session).toMatchObject({ + sessionId: SESSION_ID, + title: 'Windows prompt', + resumeCommand: `agy --conversation "${SESSION_ID}"` + }) + }) + + it('rejects files outside the targeted Antigravity transcript layout', async () => { + await expect( + parseAntigravitySessionContent( + file('/home/ada/.gemini/antigravity-cli/brain/transcript.jsonl'), + jsonLines([{ source: 'USER', type: 'REQUEST', content: 'Not a conversation transcript' }]) + ) + ).resolves.toBeNull() + }) +}) + +function file(path: string): FileWithMtime { + return { + path, + mtimeMs: Date.parse('2026-07-15T11:39:14Z'), + modifiedAt: '2026-07-15T11:39:14.000Z', + sizeBytes: 1 + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts new file mode 100644 index 00000000000..2e3a3a08b4a --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + updateTimeline +} from './session-scanner-accumulator' +import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseAntigravitySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseAntigravitySessionLines({ file, lines, platform }) +} + +export async function parseAntigravitySessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseAntigravitySessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +export function createAntigravitySessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' + // Why: the transcript has no cwd/model fields. Workspace enrichment is a + // separate, conservative history join; protobuf/SQLite blobs are unstable. + return accumulatorFoldResumeState( + createAccumulator({ agent: 'antigravity', file, sessionId }), + consumeAntigravityRecordLine + ) +} + +async function parseAntigravitySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createAntigravitySessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + + updateTimeline(accumulator, record.created_at) + const source = extractString(record.source) + const type = extractString(record.type) + const content = extractString(record.content) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const request = extractAntigravityUserRequest(content ?? '') + if (!request) { + return + } + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText(request) + addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at }) + return + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE' && content) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: content, + timestamp: record.created_at + }) + } +} + +function extractAntigravityUserRequest(content: string): string | null { + const opener = '' + const startIndex = content.indexOf(opener) + if (startIndex === -1) { + return extractString(content) + } + const bodyStart = startIndex + opener.length + const endIndex = content.indexOf('', bodyStart) + return extractString( + endIndex === -1 ? content.slice(bodyStart) : content.slice(bodyStart, endIndex) + ) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts new file mode 100644 index 00000000000..c505cb7e5ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts @@ -0,0 +1,42 @@ +import { dirname, join } from 'node:path' + +const ANTIGRAVITY_TRANSCRIPT_FILE = 'transcript.jsonl' +const ANTIGRAVITY_SYSTEM_DIR = '.system_generated' +const ANTIGRAVITY_LOGS_DIR = 'logs' + +export function antigravityConversationIdFromTranscriptPath(filePath: string): string | null { + const segments = pathSegments(filePath) + const transcriptIndex = segments.length - 1 + if ( + segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE || + segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR || + segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR + ) { + return null + } + return segments[transcriptIndex - 3] ?? null +} + +export function isAntigravityTranscriptPath(filePath: string): boolean { + return antigravityConversationIdFromTranscriptPath(filePath) !== null +} + +export function shouldDescendAntigravityBrainDirectory(name: string, depth: number): boolean { + // Why: brain entries contain large artifact trees; only the fixed transcript + // chain is part of the resumable conversation contract. + if (depth === 0) { + return true + } + if (depth === 1) { + return name === ANTIGRAVITY_SYSTEM_DIR + } + return depth === 2 && name === ANTIGRAVITY_LOGS_DIR +} + +export function antigravityHistoryPathForBrainDir(brainDir: string): string { + return join(dirname(brainDir), 'history.jsonl') +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]+/).filter(Boolean) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-source.test.ts b/src/main/ai-vault/session-scanner-antigravity-source.test.ts new file mode 100644 index 00000000000..454b3cf92f6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-source.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { + isolatedScanRoots, + writeAntigravityHistory, + writeAntigravityTranscript +} from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Antigravity AI Vault discovery', () => { + it('discovers canonical transcripts from WSL homes without indexing sibling artifacts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-wsl-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const wslHome = join(root, 'wsl-home') + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const transcriptPath = await writeAntigravityTranscript( + join(wslHome, '.gemini', 'antigravity-cli', 'brain'), + sessionId, + [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Index the WSL conversation' + } + ] + ) + await writeFile(join(dirname(transcriptPath), 'transcript_full.jsonl'), 'duplicate') + await writeAntigravityHistory(join(wslHome, '.gemini', 'antigravity-cli', 'brain'), [ + { + display: 'Index the WSL conversation', + timestamp: Date.parse('2026-07-15T11:39:10.100Z') / 1000, + workspace: '/home/ada/project' + } + ]) + + const result = await scanAiVaultSessions({ + ...roots, + wslHomeDirs: [wslHome], + platform: 'linux' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + title: 'Index the WSL conversation', + cwd: '/home/ada/project', + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: transcriptPath + }) + }) + + it('uses a unique history match in the default workspace view', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-workspace-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + const workspace = join(root, 'active-workspace') + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'ORCA_8742_REPRO' + } + ]) + + const beforeHistory = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(beforeHistory.sessions[0]?.cwd).toBeNull() + + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'ORCA_8742_REPRO', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace + } + ]) + // The transcript is unchanged, so this also covers history refresh after a + // cached incremental parse has already been stored. + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions[0]?.cwd).toBe(workspace) + expect( + filterAiVaultSessions(result.sessions, { + query: '', + agents: AI_VAULT_AGENTS, + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: true + }).map((session) => session.sessionId) + ).toEqual([sessionId]) + }) + + it('keeps workspace unknown when matching history rows are ambiguous', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-ambiguous-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa' + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Repeated prompt' + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:09.900Z'), + workspace: '/repo/one' + }, + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/two' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.cwd).toBeNull() + }) + + it('does not join workspace from a colliding truncated prompt title', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-long-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'eeeeeeee-ffff-4aaa-8bbb-cccccccccccc' + const commonPrefix = 'shared worker preamble '.repeat(7) + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: `${commonPrefix}workspace A` + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: `${commonPrefix}workspace B`, + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/wrong' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.title).toHaveLength(96) + expect(result.sessions[0]?.title).toMatch(/\.\.\.$/) + expect(result.sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts new file mode 100644 index 00000000000..a79edc380e6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + isAntigravityTranscriptPath, + shouldDescendAntigravityBrainDirectory +} from './session-scanner-antigravity-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain') + +export function antigravityDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const rootDirs = [ + options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain')) + ] + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'antigravity', + issues, + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + directoryPredicate: shouldDescendAntigravityBrainDirectory + }) + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 8a6fb183f62..d8a8e2850ac 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -106,6 +106,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir, geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..576438a8c49 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,7 +11,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), @@ -70,10 +70,11 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory (matched by its name), - // so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string) => boolean - } + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, + depth = 0 ): Promise { let entries try { @@ -88,8 +89,8 @@ export async function walkSessionFiles( if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } continue } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 1945193e6c7..54f0171e6c6 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -1,5 +1,6 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' // Line builders for the incremental-parse differential tests: each agent gets @@ -286,6 +287,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), - geminiJsonlFixture() + geminiJsonlFixture(), + antigravityFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 2fb3df21613..1819afe81f2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -23,6 +23,7 @@ function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), 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 61348ad7cec..cd4977679d2 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 @@ -1,6 +1,6 @@ import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { @@ -55,6 +55,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${fixture.seedLines.join('\n')}\n`) const stats = createSessionParseStats() @@ -87,6 +88,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('includes a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) const lastSeedLine = fixture.seedLines.at(-1) const headLines = fixture.seedLines.slice(0, -1) await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) @@ -106,6 +108,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) // A writer caught mid-record: the trailing line is invalid JSON. await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..d42da123437 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' @@ -67,6 +68,8 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null + case 'antigravity': + return () => createAntigravitySessionResumeState(candidate.file) case 'devin': case 'grok': case 'hermes': diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts index 77931d68715..487748de97a 100644 --- a/src/main/ai-vault/session-scanner-scope.test.ts +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -19,6 +19,7 @@ function scopedScanOptions(claudeProjectsDir: string, extra: Partial[] { return [ + ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ '.gemini', 'tmp' diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0a3b6c7c1a8..f69bf2939c7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -6,6 +6,7 @@ export function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), @@ -34,3 +35,37 @@ export async function writeJsonlFile(filePath: string, records: unknown[]): Prom await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, jsonLines(records)) } + +export async function writeAntigravityTranscript( + brainDir: string, + sessionId: string, + records: unknown[] +): Promise { + const transcriptPath = join(brainDir, sessionId, '.system_generated', 'logs', 'transcript.jsonl') + await writeJsonlFile(transcriptPath, records) + return transcriptPath +} + +export function writeAntigravityHistory(brainDir: string, records: unknown[]): Promise { + return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) +} + +export function writeAntigravityScannerFixture( + brainDir: string, + sessionId: string +): Promise { + return writeAntigravityTranscript(brainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-05-01T10:02:30.000Z', + content: 'Antigravity title' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-05-01T10:02:31.000Z', + content: 'Done' + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..efc6660ab1a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -12,6 +12,7 @@ export type AiVaultScanOptions = { additionalCodexSessionsDirs?: readonly string[] wslHomeDirs?: readonly string[] geminiSessionsDir?: string + antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string @@ -52,6 +53,7 @@ export type SessionFileCandidate = { agent: AiVaultAgent file: FileWithMtime codexHome: string | null + antigravityHistoryPath?: string } export type SessionFileDiscovery = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d4404341fc1..7ba80556051 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeAntigravityScannerFixture +} from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -423,6 +427,9 @@ describe('scanAiVaultSessions', () => { }) ) + const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId) + await mkdir(roots.copilotSessionsDir, { recursive: true }) await writeFile( join(roots.copilotSessionsDir, 'copilot-session.jsonl'), @@ -718,11 +725,7 @@ describe('scanAiVaultSessions', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 20 - }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -739,6 +742,7 @@ describe('scanAiVaultSessions', () => { `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` ) expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('antigravity')).toBe(`agy --conversation '${antigravitySessionId}'`) expect(commandByAgent.get('copilot')).toBe( "cd '/tmp/copilot' && copilot --resume='copilot-session'" ) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..ddb9ba7a861 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,6 +7,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + createAntigravityWorkspaceResolver, + type AntigravityWorkspaceResolver +} from './session-scanner-antigravity-history' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -54,6 +60,7 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -65,7 +72,11 @@ export async function scanAiVaultSessions( codexHome: discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined }) ) ) @@ -77,7 +88,8 @@ export async function scanAiVaultSessions( platform, executionHostId, issues, - parseStats + parseStats, + antigravityWorkspaceResolver }) const cappedSessions = parsedSessions @@ -175,6 +187,7 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -190,7 +203,13 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) + parseSessionCandidate( + candidate, + args.platform, + args.executionHostId, + args.parseStats, + args.antigravityWorkspaceResolver + ) ) ) @@ -213,10 +232,14 @@ async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, executionHostId: ExecutionHostId, - parseStats: SessionParseStats + parseStats: SessionParseStats, + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver ): Promise { try { - const session = await parseAgentSessionFileCached(candidate, platform, parseStats) + let session = await parseAgentSessionFileCached(candidate, platform, parseStats) + if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { + session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) + } return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null @@ -234,6 +257,14 @@ async function parseSessionCandidate( } } +async function readOptionalTextFile(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/main/index.ts b/src/main/index.ts index 21c4a9ab51e..ababeb6e24e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' import { @@ -54,6 +54,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' +import type { UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -118,7 +119,13 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' +import { + createSystemTray, + destroySystemTray, + setMacMenuBarIconVisible, + setTrayAttention, + type SystemTrayOptions +} from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -240,6 +247,9 @@ let keybindings: KeybindingService | null = null // sweep spare live sessions across that one reload (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() +// Why: a tray/menu-bar "Settings…" click can precede the renderer attaching +// its ui:openSettings listener; the renderer pulls this one-shot on mount. +const pendingOpenSettings = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = Promise.resolve() @@ -667,6 +677,12 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +// Why: the renderer pulls this once its ui:openSettings listener is attached so +// a tray/menu-bar Settings request queued before mount is not lost to a race. +ipcMain.handle('ui:consumePendingOpenSettings', (event) => + pendingOpenSettings.matches(event.sender.id, { consume: true }) +) + ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { @@ -789,6 +805,69 @@ function showMainWindowFromTray(): void { } } +function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + + // Why: no main-side signal proves the renderer listener is attached, so push + // and leave a one-shot intent — a mounted renderer acts on the push, an + // unmounted one pulls the intent at mount; only one fires per renderer life. + targetWindow.webContents.send('ui:openSettings') + // Why: leave an untimed intent — any TTL can be outrun by a slow cold start and + // would silently drop the Settings click. webContents-id scoping plus consume-on- + // read still prevents the intent from leaking to a later, unrelated renderer. + pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +function quitFromSystemTray(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + // Why: a real quit can still surface renderer save/discard prompts; the + // window must be visible if a hidden session vetoes shutdown. + showMainWindowFromTray() + } + // Why: set the quit latch before app.quit() so the window 'close' handler + // proceeds to teardown instead of re-hiding to the Windows tray. + isQuitting = true + app.quit() +} + +// Why: a manual check must run against a configured updater; the menu and +// tray are both clickable before anything else configures it. +function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +function getSystemTrayOptions(): SystemTrayOptions | null { + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + // Why: updater status renders in the main window; with every window + // closed a bare check would complete invisibly. + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { @@ -900,38 +979,26 @@ function openMainWindow(): BrowserWindow { }) recordCrashBreadcrumb('main_window_created') logStartupMilestone('window-created') - // Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for - // seconds while explorer.exe's notification area is busy (part of issue - // #7225's pre-paint stall), so create it after first paint. The timer - // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's 10s reveal fallback) — those can still be - // hidden to the tray on close, so the icon must exist by then. + // Why: Windows Tray construction can synchronously block on Shell_NotifyIcon, + // so both desktop status-item platforms defer creation to after first paint. let trayCreated = false const createSystemTrayDeferred = (): void => { if (trayCreated || window.isDestroyed() || isQuitting || !store) { return } trayCreated = true - // Why: Windows-only system tray. createSystemTray is idempotent and a - // no-op off win32, so calling it on each window open keeps exactly one - // live icon. - createSystemTray({ - appIcon: store.getSettings().appIcon, - onOpen: showMainWindowFromTray, - onQuit: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a real quit can still surface renderer save/discard prompts; - // the window must be visible if a hidden-to-tray session vetoes - // shutdown. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the window 'close' - // handler proceeds to teardown instead of re-hiding to the tray. - isQuitting = true - app.quit() + if (process.platform === 'darwin') { + // Why: route through syncMacMenuBarIcon so startup and the live toggle + // share one serve-mode/visibility policy. + if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { + logStartupMilestone('tray-created') } - }) - logStartupMilestone('tray-created') + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + logStartupMilestone('tray-created') + } } window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') @@ -1681,6 +1748,13 @@ app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') + store.onSettingsChanged((updates, settings) => { + if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for renderer, RPC, and future + // settings writes, so every macOS toggle updates the native item live. + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + }) // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current // single-use refresh token, and an unguarded early refresh would rotate it @@ -2002,22 +2076,14 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ - onCheckForUpdates: (options) => { - // Why: the menu is clickable before first paint; a manual check must - // run against a configured updater (see attach-main-window-services). - ensureAutoUpdaterConfigured() - return checkForUpdatesFromMenu(options) - }, + onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, - onOpenSettings: () => { - recordCrashBreadcrumb('settings_opened') - mainWindow?.webContents.send('ui:openSettings') - }, + onOpenSettings: openSettingsFromSystemMenu, onOpenSetupGuide: (targetWindow) => { recordCrashBreadcrumb('setup_guide_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 95315f6b00d..0cf672fa30f 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -126,6 +126,18 @@ describe('filesystem-list-files real git fallback', () => { ]) }) + it('bounds a non-git readdir fallback without treating the limit as an error', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) + await writeRel(tempDir, 'a.ts') + await writeRel(tempDir, 'b.ts') + + const files = await listQuickOpenFiles(tempDir, makeStore(tempDir), undefined, undefined, 1) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index 616a9a10c93..ff390d13caf 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,10 +1,15 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' -import { buildGitLsFilesArgsForQuickOpen } from '../../shared/quick-open-filter' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../../shared/quick-open-filter' import { createQuickOpenReaddirBudget, expandQuickOpenGitFileListing, - listQuickOpenFilesWithReaddir + listQuickOpenFilesWithReaddir, + parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -73,7 +78,8 @@ export async function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[], localGitOptions: { wslDistro?: string }, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { @@ -83,17 +89,20 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults, signal }) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -101,15 +110,32 @@ export async function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded scans must not retain a second repo-sized set. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: collapsed directories and gitlinks may be discarded during the + // later filesystem classification, so they cannot consume the stop cap. + return maxResults !== undefined && directFileCandidates.size >= maxResults } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -149,14 +175,19 @@ export async function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let nulIdx = buf.indexOf('\0', start) while (nulIdx !== -1) { - processPath(buf.substring(start, nulIdx)) + if (processPath(buf.substring(start, nulIdx))) { + buf = '' + finishAtLimit() + return + } start = nulIdx + 1 nulIdx = buf.indexOf('\0', start) } @@ -176,8 +207,10 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -213,22 +246,39 @@ export async function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) try { - await Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - ]) + if (maxResults === undefined) { + await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + } else { + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < maxResults) { + await runIgnoredPass() + } + } } catch (err) { killSurvivors() if (signal?.aborted) { @@ -244,9 +294,10 @@ export async function listFilesWithGit( gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort() + return files.sort().slice(0, maxResults) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index ba535a25868..946f8653dd6 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -71,6 +71,30 @@ describe('filesystem-list-files', () => { getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) + it('stops after the primary rg pass fills the result budget', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.emit('close', 0, null) + }, 0) + const result = await promise + + expect(result).toEqual(['one.ts', 'two.ts']) + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -377,6 +401,73 @@ describe('filesystem-list-files', () => { expect(result).not.toContain('node_modules/dep/index.js') }) + it('stops after primary Git files fill the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { + callIndex += 1 + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'one.ts\0two.ts') + gitP1.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['one.ts', 'two.ts']) + expect(gitP1.kill).toHaveBeenCalled() + expect(gitP2.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded Git directory placeholder consume the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const primary = createMockProcess() + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + return primary + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 1 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + }, 10) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primary.kill).toHaveBeenCalled() + }) + it('git fallback applies hidden dir blocklist', async () => { checkRgAvailableMock.mockResolvedValue(false) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 9dcd2c45038..58b1c0c0cad 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -20,7 +20,8 @@ export async function listQuickOpenFiles( rootPath: string, store: Store, excludePaths?: string[], - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -41,11 +42,21 @@ export async function listQuickOpenFiles( // can run. const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return listFilesWithGit(authorizedRootPath, excludePathPrefixes, localGitOptions, signal) + return listFilesWithGit( + authorizedRootPath, + excludePathPrefixes, + localGitOptions, + signal, + maxResults + ) } const files = new Set() - const children: ChildProcess[] = [] + const children: { + child: ChildProcess + isDone: () => boolean + finish: () => void + }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro @@ -67,7 +78,7 @@ export async function listQuickOpenFiles( let done = false let parseablePathCount = 0 - const processLine = (rawLine: string): void => { + const processLine = (rawLine: string): boolean => { const translated = wslDistroForOutput && rawLine.startsWith('/') ? toWindowsWslPath(rawLine, wslDistroForOutput) @@ -77,16 +88,20 @@ export async function listQuickOpenFiles( getQuickOpenRgOutputMode(rawLine, translated, authorizedRootPath) ) if (relPath === null) { - return + return false } parseablePathCount++ if (!shouldIncludeQuickOpenPath(relPath)) { - return + return false } if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - return + return false + } + if (maxResults !== undefined && files.size >= maxResults) { + return true } files.add(relPath) + return maxResults !== undefined && files.size >= maxResults } const child = wslAwareSpawn('rg', args, { @@ -94,14 +109,17 @@ export async function listQuickOpenFiles( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) - children.push(child) let timer: ReturnType const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) while (newlineIdx !== -1) { - processLine(buf.substring(start, newlineIdx)) + if (processLine(buf.substring(start, newlineIdx))) { + buf = '' + finishAtLimit() + return + } start = newlineIdx + 1 newlineIdx = buf.indexOf('\n', start) } @@ -125,8 +143,10 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } - if (buf) { - processLine(buf) + if (buf && processLine(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0 || code === 1) { finish() @@ -157,6 +177,8 @@ export async function listQuickOpenFiles( } } + children.push({ child, isDone: () => done, finish }) + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) @@ -176,20 +198,45 @@ export async function listQuickOpenFiles( // Why: if one rg pass fails, Promise.all rejects immediately while the // sibling scan can keep walking a huge tree until timeout. Stop it so // repeated Quick Open attempts do not accumulate local rg processes. - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() } } } try { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + if (maxResults === undefined) { + await Promise.all([runRg(primary), runRg(ignoredPass)]) + } else { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < maxResults) { + await runRg(ignoredPass) + } + } } catch (err) { killSurvivors() throw err } - return Array.from(files) + return Array.from(files).slice(0, maxResults) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 53588814848..516f839fca9 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -30,6 +30,7 @@ import { type TestSubscription = { unsubscribe: ReturnType + watching: boolean } type DeferredSubscription = { @@ -69,7 +70,7 @@ function deferredSubscription(): DeferredSubscription { return { promise, reject: rejectPromise, - resolve: () => resolvePromise({ unsubscribe }), + resolve: () => resolvePromise({ unsubscribe, watching: true }), unsubscribe } } @@ -106,6 +107,23 @@ function subscribe(sender: SenderHarness['sender'], subscriptionId: string): voi listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) } +type InitialSnapshotCallback = ( + messages: unknown[], + hasMore: boolean, + beforeOffset: number, + error?: string +) => void + +// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript +// call; transcript-watch fires it during setup, so tests invoke it directly. +function initialSnapshot(callIndex: number): InitialSnapshotCallback { + const call = subscribeTranscript.mock.calls[callIndex] + if (!call) { + throw new Error('subscribeTranscript was not called') + } + return (call[0] as { onInitialSnapshot: InitialSnapshotCallback }).onInitialSnapshot +} + function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { const listener = listeners.get('nativeChat:unsubscribe') if (!listener) { @@ -225,4 +243,39 @@ describe('nativeChat subscribe lifecycle', () => { expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) + + it('forwards an initial-drain error onto the snapshot frame', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(5) + + subscribe(renderer.sender, 'drain-error') + // transcript-watch delivers the drain error synchronously via onInitialSnapshot; + // invoke the captured callback to exercise the handler's forwarding closure. + initialSnapshot(0)([], false, 0, 'Transcript unavailable') + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-error', + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + }) + }) + + it('omits error from the snapshot frame on a clean initial drain', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(6) + + subscribe(renderer.sender, 'drain-clean') + initialSnapshot(0)([], false, 0) + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-clean', + frame: { type: 'snapshot', messages: [], hasMore: false } + }) + }) }) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index d16a39b1f13..4d0e1765a44 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -58,6 +58,7 @@ async function invokeReadSession(args: { agent: string sessionId: string limit?: number + transcriptPath?: string }): Promise { registerNativeChatHandlers() const handler = handlers.get('nativeChat:readSession') @@ -68,6 +69,17 @@ async function invokeReadSession(args: { } describe('nativeChat:readSession handler', () => { + it('preserves notFound so a just-created session stays in retry/loading', async () => { + const result = (await invokeReadSession({ + agent: 'claude', + sessionId: 'missing-session', + transcriptPath: join(tmpdir(), 'orca-native-chat-ipc-does-not-exist.jsonl') + })) as { error?: string; notFound?: true } + + expect(result.error).toBeDefined() + expect(result.notFound).toBe(true) + }) + it('resolves a Claude transcript and returns the full conversation', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) tempRoots.push(root) @@ -152,7 +164,7 @@ describe('nativeChat:readSession handler', () => { } }) - it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) const projectsDir = join(root, '.claude', 'projects') @@ -214,13 +226,14 @@ describe('nativeChat:readSession handler', () => { })}\n` ) - // Seed-at-0 means the first appended event carries the whole-file re-read; - // the new turn 'a-1' arrives across one of the appended events. Collect ids - // from every appended event and assert the new turn shows up. + // The first frame is a bounded snapshot and later frames are appends. + // Collect ids across both and assert the new turn shows up. const appendedIds = (): string[] => sent .filter((s) => s.channel === 'nativeChat:appended') - .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + .flatMap((s) => + (s.payload as { frame: { messages: { id: string }[] } }).frame.messages.map((m) => m.id) + ) await waitFor(() => appendedIds().includes('a-1')) const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! const payload = appendedEvent.payload as { subscriptionId: string } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 3ed4e9b74a6..227918a68f3 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,12 +1,10 @@ import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { - clearNativeChatTranscriptCache, - readNativeChatTranscriptCached -} from '../native-chat/transcript-read-cache' +import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, + readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' @@ -25,27 +23,20 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -// Why: render only the most recent turns so switching to chat view on a long -// session (thousands of messages) doesn't stall on building that many message -// components. The full transcript is still cached; only the returned slice is -// capped. The renderer raises `limit` to page in older history; live appends -// extend it from there. +// Why: render and parse only the recent window so long transcripts do not stall +// either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { - if (!('messages' in result) || result.messages.length <= limit) { - return result - } - return { ...result, messages: result.messages.slice(-limit) } -} - async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - // Desktop is full-class: window by count only, no char truncation. - const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) - return windowTranscript(result, limit) + return readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath: args.transcriptPath, + limit + }) } export type NativeChatSubscribeArgs = { @@ -56,11 +47,15 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + limit?: number } export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatMessage[] + frame: + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } } type LiveSubscription = { @@ -144,6 +139,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } const { subscriptionId, agent, sessionId, transcriptPath } = args + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) @@ -154,11 +150,36 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg agent, sessionId, transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, _beforeOffset, error) => { + if (sender.isDestroyed()) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'snapshot', messages, hasMore, ...(error ? { error } : {}) } + } + sender.send('nativeChat:appended', payload) + }, + onReplace: (messages, hasMore) => { + if (sender.isDestroyed()) { + return + } + sender.send('nativeChat:appended', { + subscriptionId, + frame: { type: 'replacement', messages, hasMore } + } satisfies NativeChatAppendedPayload) + }, onAppend: (messages) => { if (sender.isDestroyed()) { return } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'appended', messages } + } sender.send('nativeChat:appended', payload) } }) @@ -182,6 +203,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } bySubId.set(subscriptionId, { subscription }) liveSubscriptions.set(sender.id, bySubId) + if (!subscription.watching && !sender.isDestroyed()) { + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + } + sender.send('nativeChat:appended', payload) + } } /** Test-only: drop all live and pending transcript subscriptions between runs. */ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 4eb8938f2b4..9d6c825498d 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -436,8 +436,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // hold back the dot. It rides the notification dispatch, so it follows the // renderer's per-source decision to notify: bells always reach here, while // an agent completion is suppressed upstream when its notification is - // disabled. Tray exists only on Windows, so setTrayAttention no-ops - // elsewhere. + // disabled. The status item exists on Windows and macOS, so + // setTrayAttention lights its attention dot there and no-ops on Linux. if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null if (!isMainWindowVisible(activeWindow)) { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 5dd3cbc8dab..85cd653185b 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -39,6 +39,19 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves OpenClaude sessions from the Claude transcript layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-openclaude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-openclaude.jsonl') + await writeFile(target, '{}\n') + + await expect( + resolveSessionFilePath('openclaude', 'sess-openclaude', { claudeProjectsDir }) + ).resolves.toBe(target) + }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { const root = await makeRoot('orca-native-chat-resolve-grok-') const grokSessionsDir = join(root, 'grok-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index adfa3856f0e..33cf8c23823 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' 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 { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { @@ -66,6 +67,10 @@ export async function resolveSessionFilePath( sessionId: string, options: ResolveSessionFileOptions = {} ): Promise { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (!transcriptAgent) { + return null + } // Why: the hook's transcript_path is the exact file the agent is writing, so it // beats reconstructing a path from the session id. Guard with existsSync so a // stale/remote path falls through to the id-based search rather than returning @@ -80,13 +85,13 @@ export async function resolveSessionFilePath( return null } - if (agent === 'claude') { + if (transcriptAgent === 'claude') { return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } return null diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts new file mode 100644 index 00000000000..ffb3ad83cae --- /dev/null +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -0,0 +1,103 @@ +import { open, stat } from 'node:fs/promises' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, + type NativeChatLineDecoder +} from './transcript-tail-reader' + +const APPEND_BATCH_MESSAGE_LIMIT = 40 + +export type IncrementalTranscriptState = { + offset: number + pendingChunks: Buffer[] + pendingStart: number + pendingBytes: number + droppingOversizedRecord: boolean +} + +export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { + state.offset = 0 + state.pendingChunks.length = 0 + state.pendingStart = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false +} + +export async function readIncrementalTranscriptMessages( + filePath: string, + state: IncrementalTranscriptState, + decode: NativeChatLineDecoder, + onBatch?: (messages: NativeChatMessage[]) => void +): Promise { + const end = (await stat(filePath)).size + if (end <= state.offset) { + return [] + } + const messages: NativeChatMessage[] = [] + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) + let absoluteOffset = state.offset + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let segmentStart = 0 + let newline = chunk.indexOf(0x0a) + while (newline >= 0) { + retainPart(chunk.subarray(segmentStart, newline)) + if (!state.droppingOversizedRecord) { + decodeLine() + } + resetPendingLine(absoluteOffset + newline + 1) + segmentStart = newline + 1 + newline = chunk.indexOf(0x0a, segmentStart) + } + if (segmentStart < chunk.length) { + retainPart(chunk.subarray(segmentStart)) + } + absoluteOffset += chunk.length + state.offset = absoluteOffset + } + return messages + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (state.droppingOversizedRecord) { + return + } + state.pendingBytes += part.length + if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + state.pendingChunks.length = 0 + state.droppingOversizedRecord = true + return + } + state.pendingChunks.push(part) + } + + function resetPendingLine(nextStart: number): void { + state.pendingChunks.length = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false + state.pendingStart = nextStart + } + + function decodeLine(): void { + let line = Buffer.concat(state.pendingChunks).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, state.pendingStart)) + if (!message) { + return + } + messages.push(message) + if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { + onBatch(messages.splice(0)) + } + } +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index cd87f2bdd59..ff48548804d 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { readNativeChatTranscript } from './transcript-reader' +import { + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTail, + readNativeChatTranscriptTailFile +} from './transcript-tail-reader' let tempRoots: string[] = [] @@ -24,6 +29,20 @@ async function writeFixture(prefix: string, records: unknown[]): Promise } describe('readNativeChatTranscript (claude)', () => { + it('decodes OpenClaude with the Claude transcript format', async () => { + const filePath = await writeFixture('orca-native-chat-openclaude-', [ + { + type: 'assistant', + uuid: 'openclaude-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } + ]) + + await expect( + readNativeChatTranscript('openclaude', 'session', { filePath }) + ).resolves.toMatchObject({ messages: [{ id: 'openclaude-assistant' }] }) + }) + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { const records: unknown[] = [] // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. @@ -280,3 +299,37 @@ describe('readNativeChatTranscript (errors)', () => { } }) }) + +describe('readNativeChatTranscriptTailFile', () => { + it('keeps a missing tail retry-worthy for the live-session seed', async () => { + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'sess', + filePath: join(tmpdir(), 'orca-native-chat-tail-does-not-exist.jsonl'), + limit: 40 + }) + + expect(result).toMatchObject({ notFound: true }) + }) + + it('windows to nothing for a non-positive limit instead of the whole tail', async () => { + const decode = nativeChatLineDecoderForAgent('claude')! + const filePath = await writeFixture('orca-native-chat-tail-limit-', [ + { + type: 'assistant', + uuid: 'a-1', + message: { role: 'assistant', content: [{ type: 'text', text: 'one' }] } + }, + { + type: 'assistant', + uuid: 'a-2', + message: { role: 'assistant', content: [{ type: 'text', text: 'two' }] } + } + ]) + + const result = await readNativeChatTranscriptTailFile(filePath, 0, decode, true) + + expect(result.messages).toEqual([]) + expect(result.hasMore).toBe(false) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index e2ea727c809..182296bda1d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,5 +1,6 @@ import { createReadStream } from 'node:fs' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { @@ -37,13 +38,14 @@ export async function readNativeChatTranscript( return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { - if (agent === 'claude') { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts new file mode 100644 index 00000000000..9ac029d5913 --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -0,0 +1,200 @@ +import { open, stat } from 'node:fs/promises' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { transcriptFallbackId } from './transcript-fallback-id' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 +const TAIL_CHUNK_BYTES = 64 * 1024 + +export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { + return decodeClaudeTranscriptLine + } + if (transcriptAgent === 'codex') { + return decodeCodexTranscriptLine + } + if (transcriptAgent === 'grok') { + return decodeGrokTranscriptLine + } + return null +} + +export async function readNativeChatTranscriptTailFile( + filePath: string, + limit: number, + decode: NativeChatLineDecoder, + includeTrailingLine = false, + endOffset?: number +): Promise<{ + messages: NativeChatMessage[] + consumedTo: number + hasMore: boolean + beforeOffset: number +}> { + const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + if (end === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const handle = await open(filePath, 'r') + const lineParts: Buffer[] = [] + let lineBytes = 0 + let lineOversized = false + try { + const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + if (consumedTo === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const newestFirst: { message: NativeChatMessage; offset: number }[] = [] + const finalByte = Buffer.allocUnsafe(1) + await handle.read(finalByte, 0, 1, consumedTo - 1) + let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) + while (cursor > 0 && newestFirst.length <= limit) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + let segmentEnd = bytesRead + for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { + if (buffer[index] !== 0x0a) { + continue + } + retainPart(buffer.subarray(index + 1, segmentEnd)) + if (!lineOversized) { + decodeLine(start + index + 1, newestFirst) + } + resetLine() + segmentEnd = index + } + if (segmentEnd > 0) { + retainPart(buffer.subarray(0, segmentEnd)) + } + cursor = start + } + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { + decodeLine(0, newestFirst) + } + const chronological = newestFirst.toReversed() + // Why: slice(-0) returns the whole array, so a non-positive limit must + // window to nothing explicitly rather than leak every buffered record. + const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : [] + return { + messages: selected.map((entry) => entry.message), + consumedTo, + hasMore: limit > 0 && chronological.length > limit, + beforeOffset: selected[0]?.offset ?? end + } + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (lineOversized) { + return + } + lineBytes += part.length + if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + lineParts.length = 0 + lineOversized = true + return + } + lineParts.push(part) + } + + function resetLine(): void { + lineParts.length = 0 + lineBytes = 0 + lineOversized = false + } + + function decodeLine( + lineOffset: number, + messages: { message: NativeChatMessage; offset: number }[] + ): void { + let line = Buffer.concat([...lineParts].toReversed()).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, lineOffset)) + if (message) { + messages.push({ message, offset: lineOffset }) + } + } +} + +async function findLastCompleteLineEnd( + handle: Awaited>, + end: number +): Promise { + const lastByte = Buffer.allocUnsafe(1) + await handle.read(lastByte, 0, 1, end - 1) + if (lastByte[0] === 0x0a) { + return end + } + let cursor = end + while (cursor > 0) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) + if (newline >= 0) { + return start + newline + 1 + } + cursor = start + } + return 0 +} + +export async function readNativeChatTranscriptTail( + args: ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + transcriptPath?: string + filePath?: string + limit: number + beforeOffset?: number + } +): Promise< + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } +> { + const decode = nativeChatLineDecoderForAgent(args.agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!decode) { + return { error: 'Transcript unavailable' } + } + // Why: a new agent session can report its id before the first JSONL flush; + // callers keep that miss in loading/retry rather than showing a false error. + if (!filePath) { + return { error: 'Transcript unavailable', notFound: true } + } + try { + const result = await readNativeChatTranscriptTailFile( + filePath, + args.limit, + decode, + true, + args.beforeOffset + ) + return { + messages: result.messages, + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' + ? { error: message, notFound: true } + : { error: message } + } +} diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts new file mode 100644 index 00000000000..d610f5a1536 --- /dev/null +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -0,0 +1,310 @@ +import { watch, type FSWatcher } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { basename, dirname } from 'node:path' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import type { ResolveSessionFileOptions } from './session-file-resolver' +import { + readIncrementalTranscriptMessages, + resetIncrementalTranscriptState, + type IncrementalTranscriptState +} from './transcript-incremental-reader' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + onAppend: (messages: NativeChatMessage[]) => void + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + /** Set when the initial drain could not deliver a transcript; the subscriber + * surfaces it as an error snapshot so a watching client never sticks on + * 'loading'. Empty messages accompany it. */ + error?: string + ) => void + onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void + initialLimit?: number + filePath?: string + debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number +} + +export type NativeChatTranscriptSubscription = { + unsubscribe: () => void + watching: boolean +} + +const DEFAULT_DEBOUNCE_MS = 40 +const ROTATION_RETRY_MS = 25 +const MAX_ROTATION_RETRY_MS = 2_000 +let activeWatcherCount = 0 + +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { + const value = await stat(filePath) + return { identity: `${value.dev}:${value.ino}`, size: value.size } +} + +async function boundaryFingerprint(filePath: string, offset: number): Promise { + if (offset <= 0) { + return '' + } + const start = Math.max(0, offset - 64) + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.allocUnsafe(offset - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + return buffer.subarray(0, bytesRead).toString('base64') + } finally { + await handle.close() + } +} + +/** + * Install the live-tail engine on an already-resolved file path. Returns null + * (rather than throwing) when the file doesn't exist yet or `watch()` fails — + * e.g. an explicit transcriptPath whose first JSONL line hasn't flushed, or a + * file that vanished between resolve and install — so the caller falls back to + * the resolve-poll below instead of surfacing a hard error (#8401). The + * existence gate matters because the engine watches the parent directory (see + * bindWatcher), which succeeds even when the file itself is missing. + */ +export async function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + args: SubscribeNativeChatTranscriptArgs +): Promise { + try { + await stat(filePath) + } catch { + return null + } + const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args + + const state: IncrementalTranscriptState = { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } + let watchedIdentity: string | null = null + let watchedBoundary = '' + let initialDrain = true + // Guards the one-time error snapshot emitted when the initial drain throws, so + // a persistently-failing retry loop can't spam the subscriber with error frames. + let initialErrorEmitted = false + let closed = false + let reading = false + let pendingReadRequested = false + let rotationRetryCount = 0 + let debounceTimer: ReturnType | null = null + let watcher: FSWatcher | null = null + let watcherNeedsRebind = false + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + function scheduleRotationRetry(): void { + if (closed || debounceTimer) { + return + } + const retryDelay = Math.min( + ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), + MAX_ROTATION_RETRY_MS + ) + rotationRetryCount += 1 + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, retryDelay) + } + + async function readAndEmitAppends(): Promise { + const remaining = await readIncrementalTranscriptMessages( + filePath, + state, + decode, + (messages) => { + if (!closed) { + onAppend(messages) + } + } + ) + if (!closed && remaining.length > 0) { + onAppend(remaining) + } + } + + async function drainOnce(): Promise { + const current = await fileVersion(filePath) + const currentBoundary = await boundaryFingerprint(filePath, state.offset) + if (closed) { + return + } + if (watcherNeedsRebind) { + bindWatcher() + } + const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const contentReplaced = + identityChanged || + current.size < state.offset || + (state.offset > 0 && watchedBoundary !== currentBoundary) + if (contentReplaced) { + resetIncrementalTranscriptState(state) + } + watchedIdentity = current.identity + + const replacementSnapshot = + // Why: 0 is a valid window — an explicit undefined check keeps an empty + // snapshot empty instead of falling back to an unbounded incremental read. + contentReplaced && !initialDrain && onReplace && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (replacementSnapshot && onReplace) { + state.offset = replacementSnapshot.consumedTo + state.pendingStart = state.offset + onReplace( + replacementSnapshot.messages, + replacementSnapshot.hasMore, + replacementSnapshot.beforeOffset + ) + await readAndEmitAppends() + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + return + } + + const initialSnapshot = + initialDrain && onInitialSnapshot && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (initialDrain && onInitialSnapshot) { + initialDrain = false + if (initialSnapshot) { + state.offset = initialSnapshot.consumedTo + state.pendingStart = state.offset + onInitialSnapshot( + initialSnapshot.messages, + initialSnapshot.hasMore, + initialSnapshot.beforeOffset + ) + await readAndEmitAppends() + } else { + const messages = await readIncrementalTranscriptMessages(filePath, state, decode) + onInitialSnapshot(messages, false, 0) + } + } else { + initialDrain = false + await readAndEmitAppends() + } + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + } + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + await drainOnce() + } catch { + // Why: unlink/recreate can detach fs.watch from the pathname. Keep one + // capped-backoff retry alive until a successor appears or we unsubscribe. + // A still-pending initial drain also surfaces one error snapshot so a + // watching client isn't stranded at 'loading' when the read keeps + // throwing; initialDrain stays true so a recovered read can still win. + if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + initialErrorEmitted = true + onInitialSnapshot([], false, 0, 'Transcript unavailable') + } + scheduleRotationRetry() + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function bindWatcher(): void { + const watchedName = basename(filePath) + // Why: file watchers stay bound to an unlinked inode on macOS. Watching + // the parent keeps observing a successor even after a long recreate gap. + const nextWatcher = watch(dirname(filePath), (_event, changedName) => { + if (changedName === null || changedName.toString() === watchedName) { + scheduleDrain() + } + }) + nextWatcher.on('error', () => { + // Why: Windows can emit EPERM when a watched directory disappears. An + // error listener prevents a process crash and retries against its successor. + if (closed || watcher !== nextWatcher) { + return + } + watcherNeedsRebind = true + nextWatcher.close() + watcher = null + scheduleRotationRetry() + }) + watcher = nextWatcher + watcherNeedsRebind = false + } + + try { + bindWatcher() + } catch { + // File vanished between resolve and watch. + return null + } + activeWatcherCount++ + scheduleDrain() + + return { + watching: true, + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher?.close() + watcher = null + activeWatcherCount-- + } + } +} diff --git a/src/main/native-chat/transcript-watch-error.test.ts b/src/main/native-chat/transcript-watch-error.test.ts new file mode 100644 index 00000000000..2d3b0828664 --- /dev/null +++ b/src/main/native-chat/transcript-watch-error.test.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { close: ReturnType })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + const watcher = Object.assign(new EventEmitter(), { close: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('native chat transcript watcher errors', () => { + it('handles a watcher error and rebinds after the directory is readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onAppend: () => {}, + debounceMs: 0 + }) + + expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow() + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('keeps retrying after the old recovery window and tails a recreated directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce()) + + await rm(root, { recursive: true, force: true }) + watchers[0]!.emit('error', new Error('EPERM')) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(watchMock).toHaveBeenCalledTimes(1) + + await mkdir(root, { recursive: true }) + await writeFile(filePath, claudeLine('u-recreated', 'user', 'back')) + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 }) + + await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onAppend.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })]) + ) + ) + + subscription.unsubscribe() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('surfaces an error snapshot when the initial drain throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-')) + roots.push(root) + // A directory sitting at the transcript path: it exists (so install does not + // defer to the not-yet-flushed resolve poll, #8401) but every tail read + // throws EISDIR — a persistent real read error, not a missing file. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + expect(subscription.watching).toBe(true) + + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + // Surfaced once, not spammed by the capped rotation retry loop. + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(onInitialSnapshot).toHaveBeenCalledOnce() + + subscription.unsubscribe() + }) + + it('still wins with a real initial snapshot once the transcript becomes readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-')) + roots.push(root) + // Same unreadable-directory setup as above; the error frame must not be + // terminal once the path is replaced with a readable transcript. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend: () => {}, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + + // initialDrain stays true after the error, so a recovered read delivers the + // real snapshot instead of stranding the client on the error frame. + await rm(filePath, { recursive: true, force: true }) + await writeFile(filePath, claudeLine('u-recovered', 'user', 'back')) + watchCallbacks[0]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onInitialSnapshot.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'u-recovered' })]) + ) + ) + + subscription.unsubscribe() + }) +}) + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts new file mode 100644 index 00000000000..be4e60dede3 --- /dev/null +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + resolve: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-watch-engine', () => ({ + getActiveNativeChatWatcherCount: vi.fn(() => 0), + installTranscriptWatcher: mocks.install +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +describe('native chat transcript resolve polling', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.install.mockReset().mockReturnValue(null) + mocks.resolve.mockReset().mockResolvedValue(null) + }) + + afterEach(() => vi.useRealTimers()) + + it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: '/missing/exact.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install.mock.calls.length).toBeGreaterThan(1) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(4_900) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + + subscription.unsubscribe() + const callsAfterUnsubscribe = mocks.install.mock.calls.length + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) + }) + + it('keeps resolving on every retry when no exact hook path is available', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(35) + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) + subscription.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4ff621d05bc..512da6b014e 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -1,9 +1,13 @@ -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../shared/native-chat-types' -import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' +import { + getActiveNativeChatWatcherCount, + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from './transcript-watch' let tempRoots: string[] = [] @@ -52,6 +56,191 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + it('delivers the offset-0 drain as one initial snapshot, then only live appends', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const snapshots: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: (messages) => appends.push(messages), + debounceMs: 5 + }) + + expect(sub.watching).toBe(true) + await waitFor(() => snapshots.length === 1) + expect(snapshots[0]?.map((message) => message.id)).toEqual(['u-1']) + expect(appends).toEqual([]) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => appends.flat().some((message) => message.id === 'a-1')) + sub.unsubscribe() + + expect(snapshots).toHaveLength(1) + expect(appends.flat().map((message) => message.id)).toEqual(['a-1']) + }) + + it('delivers an empty initial snapshot so clients do not remain loading', async () => { + const filePath = await tempFile('') + const snapshots: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([[]]) + }) + + it('emits a bulk append in bounded ordered batches', async () => { + const filePath = await tempFile('') + const batches: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + Array.from({ length: 95 }, (_unused, index) => + claudeLine(`bulk-${index}`, 'user', `message-${index}`) + ).join('') + ) + + await waitFor(() => batches.flat().length === 95) + sub.unsubscribe() + expect(batches.map((batch) => batch.length)).toEqual([40, 40, 15]) + expect(batches.flat().map((message) => message.id)).toEqual( + Array.from({ length: 95 }, (_unused, index) => `bulk-${index}`) + ) + }) + + it('drops one oversized record without retaining or blocking the next append', async () => { + const filePath = await tempFile('') + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + claudeLine('oversized', 'user', 'x'.repeat(2 * 1024 * 1024)) + + claudeLine('after-oversized', 'user', 'still delivered') + ) + + await waitFor(() => seen.some((message) => message.id === 'after-oversized')) + sub.unsubscribe() + expect(seen.some((message) => message.id === 'oversized')).toBe(false) + }) + + it('returns a bounded tail snapshot with exact pagination state', async () => { + const transcript = Array.from({ length: 800 }, (_unused, index) => + claudeLine(`u-${index}`, 'user', `message-${index}-${'x'.repeat(100)}`) + ).join('') + const filePath = await tempFile(transcript) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 2, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-798', 'u-799'], hasMore: true }]) + }) + + it('keeps an explicit zero-limit snapshot empty instead of reading unbounded', async () => { + const filePath = await tempFile(claudeLine('u-0', 'user', 'hello')) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 0, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: [], hasMore: false }]) + }) + + it('pages older history by byte cursor without resending the growing tail', async () => { + const filePath = await tempFile( + Array.from({ length: 10 }, (_unused, index) => + claudeLine(`page-${index}`, 'user', `message-${index}`) + ).join('') + ) + const newest = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3 + }) + if ('error' in newest) { + throw new Error(newest.error) + } + const older = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3, + beforeOffset: newest.beforeOffset + }) + + expect(newest.messages.map((message) => message.id)).toEqual(['page-7', 'page-8', 'page-9']) + expect('messages' in older && older.messages.map((message) => message.id)).toEqual([ + 'page-4', + 'page-5', + 'page-6' + ]) + }) + + it('decodes multi-chunk records once into the bounded tail order', async () => { + const large = 'x'.repeat(200_000) + const filePath = await tempFile( + claudeLine('u-large-1', 'user', large) + claudeLine('u-large-2', 'user', large) + ) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 1, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-large-2'], hasMore: true }]) + }) + it('re-emits from the top on first drain so appended turns are never dropped', async () => { const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) const batches: NativeChatMessage[][] = [] @@ -233,6 +422,109 @@ describe('subscribeNativeChatTranscript', () => { expect(ids).toContain('a-2') }) + it('detects same-size and larger in-place transcript replacement', async () => { + const filePath = await tempFile(claudeLine('u-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'u-old')) + + await writeFile(filePath, claudeLine('u-new', 'user', 'new')) + await waitFor(() => seen.some((message) => message.id === 'u-new')) + await writeFile(filePath, claudeLine('u-bigger', 'user', 'larger replacement text')) + await waitFor(() => seen.some((message) => message.id === 'u-bigger')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'u-new')).toHaveLength(1) + expect(seen.filter((message) => message.id === 'u-bigger')).toHaveLength(1) + }) + + it('keeps watching after atomic rename replacement', async () => { + const filePath = await tempFile(claudeLine('atomic-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const seen: NativeChatMessage[] = [] + const replacements: string[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 40, + onInitialSnapshot: (messages) => seen.push(...messages), + onReplace: (messages) => { + replacements.push(messages.map((message) => message.id)) + seen.splice(0, seen.length, ...messages) + }, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'atomic-old')) + + await writeFile(replacementPath, claudeLine('atomic-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await waitFor(() => seen.some((message) => message.id === 'atomic-new')) + await appendFile(filePath, claudeLine('atomic-followup', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'atomic-followup')) + sub.unsubscribe() + + expect(replacements).toEqual([['atomic-new']]) + expect(seen.some((message) => message.id === 'atomic-old')).toBe(false) + expect(seen.filter((message) => message.id === 'atomic-followup')).toHaveLength(1) + }) + + it('does not resurrect the watcher when unsubscribe races an atomic replacement', async () => { + const filePath = await tempFile(claudeLine('race-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const before = getActiveNativeChatWatcherCount() + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'race-old')) + + await writeFile(replacementPath, claudeLine('race-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await new Promise((resolve) => setTimeout(resolve, 0)) + sub.unsubscribe() + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(getActiveNativeChatWatcherCount()).toBe(before) + await appendFile(filePath, claudeLine('race-after', 'assistant', 'must stay closed')) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(seen.some((message) => message.id === 'race-after')).toBe(false) + }) + + it('recovers after an unlink/recreate gap outlasts the fast retry window', async () => { + const filePath = await tempFile(claudeLine('unlink-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'unlink-old')) + + await rm(filePath) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + await writeFile(filePath, claudeLine('unlink-new', 'user', 'replacement')) + await waitFor(() => seen.some((message) => message.id === 'unlink-new')) + await appendFile(filePath, claudeLine('unlink-after', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'unlink-after')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'unlink-after')).toHaveLength(1) + }) + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { const before = getActiveNativeChatWatcherCount() const sub = await subscribeNativeChatTranscript({ @@ -240,6 +532,7 @@ describe('subscribeNativeChatTranscript', () => { sessionId: '', onAppend: () => {} }) + expect(sub.watching).toBe(false) expect(getActiveNativeChatWatcherCount()).toBe(before) // Must not throw. sub.unsubscribe() diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1f55c33124a..f3228d970fc 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,225 +1,19 @@ -// Live transcript tailing: watch a resolved session JSONL file and emit only -// the messages parsed from bytes appended since the last read. Modeled on the -// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's -// skipInitialBytes), but specialized to the NativeChatMessage record decoders. -// -// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription -// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes -// the watcher and clears the timer synchronously, and the module tracks the live -// watcher count so tests can assert no watcher survives teardown. - -import { watch, type FSWatcher } from 'node:fs' -import { open, stat } from 'node:fs/promises' -import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { extname } from 'node:path' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' import { - decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, - decodeGrokTranscriptLine -} from './transcript-line-decoders' -import { decodeTranscriptStream } from './transcript-stream-lines' + installTranscriptWatcher, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' +import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { - agent: AgentType - sessionId: string - /** Called with the newly-appended messages whenever the file grows. Never - * called with an empty array. */ - onAppend: (messages: NativeChatMessage[]) => void - /** Resolve directly to this file, skipping path discovery (used by tests). */ - filePath?: string - /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ - debounceMs?: number - /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests - * don't wait out the production backoff. Production ignores this and backs - * off from 500ms to a 5s cap. */ - resolvePollIntervalMs?: number -} - -export type NativeChatTranscriptSubscription = { - /** Closes the watcher and releases the file handle. Idempotent. */ - unsubscribe: () => void -} - -// Why: a single watch event can fire several times for one append; we read from -// the last byte offset so re-entrant reads never re-emit prior messages. Each -// decoder is stateless per-line, so tailing reuses the same record→message -// mapping the full reader uses. -const DEFAULT_DEBOUNCE_MS = 40 - -// Why: process-wide count of live FSWatchers opened by this module. The U4 leak -// test asserts this returns to zero after unsubscribe so a forgotten handle is -// caught deterministically rather than relying on OS fd inspection. -let activeWatcherCount = 0 - -/** Test-only: number of fs watchers this module currently holds open. */ -export function getActiveNativeChatWatcherCount(): number { - return activeWatcherCount -} - -function lineDecoderForAgent( - agent: AgentType -): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { - if (agent === 'claude') { - return decodeClaudeTranscriptLine - } - if (agent === 'codex') { - return decodeCodexTranscriptLine - } - if (agent === 'grok') { - return decodeGrokTranscriptLine - } - return null -} - -async function fileSize(filePath: string): Promise { - try { - return (await stat(filePath)).size - } catch { - return 0 - } -} - -/** - * Read bytes [start, end) of the file and decode each complete line into a - * NativeChatMessage. Opens its own fd and always closes it (no leak on the read - * path, distinct from the long-lived watcher). Returns the messages plus the - * byte offset actually consumed so a partially-written trailing line is re-read - * on the next append rather than dropped. - */ -async function readAppendedMessages( - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { - const end = await fileSize(filePath) - if (end <= start) { - // File shrank (rotation/replacement) or unchanged — caller resets offset. - return { messages: [], consumedTo: end } - } - - const handle = await open(filePath, 'r') - try { - const stream = handle.createReadStream({ - encoding: 'utf-8', - start, - end: end - 1, - autoClose: false - }) - const { messages, consumedBytes } = await decodeTranscriptStream( - stream, - filePath, - start, - decode, - false - ) - return { messages, consumedTo: start + consumedBytes } - } finally { - await handle.close() - } -} - -/** - * Install a live-tail watcher on an already-resolved file path. Returns null - * (rather than throwing) when `watch()` fails — e.g. the file vanished between - * resolve and install — so the caller can fall back to the resolve-poll below - * instead of surfacing a hard error. - */ -function installTranscriptWatcher( - filePath: string, - decode: (line: string, fallbackId: string) => NativeChatMessage | null, - onAppend: (messages: NativeChatMessage[]) => void, - debounceMs?: number -): NativeChatTranscriptSubscription | null { - // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This - // closes the read/subscribe race — a turn appended between the caller's - // readSession EOF and the watcher install is still emitted. Re-emitted lines - // collapse by deterministic id in the assembler (no dup, no drop). Subsequent - // drains use the incremental offset so the full re-read happens only once. - let offset = 0 - let closed = false - let reading = false - let pendingReadRequested = false - let debounceTimer: ReturnType | null = null - - async function drain(): Promise { - if (closed) { - return - } - if (reading) { - // A read is already in flight; mark that another pass is needed so rapid - // successive appends coalesce without dropping the trailing one. - pendingReadRequested = true - return - } - reading = true - try { - do { - pendingReadRequested = false - try { - const currentSize = await fileSize(filePath) - if (currentSize < offset) { - // Rotation/replacement/truncation: re-read from the top. - offset = 0 - } - const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) - offset = consumedTo - if (!closed && messages.length > 0) { - onAppend(messages) - } - } catch { - // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) - // must not leave the subscription permanently deaf. Stop this drain; - // the finally resets `reading` so a later fs event re-arms the read. - break - } - } while (pendingReadRequested && !closed) - } finally { - reading = false - } - } - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } - - let watcher: FSWatcher - try { - watcher = watch(filePath, scheduleDrain) - } catch { - // File vanished between resolve and watch. - return null - } - activeWatcherCount++ - - // Why: on some platforms fs.watch can miss the very first append that lands - // between offset-seed and watcher install. Kick one debounced drain so a - // turn written immediately after subscribe is still picked up. - scheduleDrain() - - return { - unsubscribe: () => { - if (closed) { - return - } - closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher.close() - activeWatcherCount-- - } - } -} +export { readNativeChatTranscriptTail } from './transcript-tail-reader' +export { + getActiveNativeChatWatcherCount, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' /** One resolve+install attempt. Returns null when the file isn't resolvable * yet, or vanished between resolve and `watch()` — either case is retried by @@ -232,22 +26,30 @@ async function attemptInstall( if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) + return installTranscriptWatcher(filePath, decode, args) } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a // brand-new session's first JSONL line (#8401) — resolveSessionFilePath -// genuinely has nothing to find yet. Poll for it instead of going deaf; each -// attempt is a cheap glob, so the cost of polling is negligible next to the -// gap it covers. +// genuinely has nothing to find yet. Poll for it instead of going deaf. Exact +// hook paths are probed on every retry; the recursive +// session-id fallback runs less often because a large Claude tree is expensive. const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 +const FALLBACK_RESOLVE_POLL_MS = 5_000 + +function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { + const path = args.transcriptPath?.trim() + return path && extname(path) === '.jsonl' ? path : null +} /** * Background retry loop for a transcript that hasn't been resolvable yet. * Returns a subscription immediately (per subscribeNativeChatTranscript's * contract); the loop keeps retrying resolve+install until it succeeds or - * unsubscribe() cancels it. + * unsubscribe() cancels it. Reports watching:true — the engine's first drain + * delivers the initial snapshot once the file appears, so subscribers must not + * settle a merely not-yet-flushed transcript into a permanent error (#8401). */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -257,15 +59,23 @@ function subscribeViaResolvePoll( let installed: NativeChatTranscriptSubscription | null = null let pollTimer: ReturnType | null = null let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + let lastFallbackResolveAt = Date.now() + const exactPath = exactTranscriptPath(args) function scheduleAttempt(): void { if (closed) { return } - pollTimer = setTimeout(() => { - pollTimer = null - void runAttempt() - }, delay) + const untilFallbackResolve = exactPath + ? Math.max(0, FALLBACK_RESOLVE_POLL_MS - (Date.now() - lastFallbackResolveAt)) + : delay + pollTimer = setTimeout( + () => { + pollTimer = null + void runAttempt() + }, + Math.min(delay, untilFallbackResolve) + ) // Why: never hold the event loop open (headless `orca serve` shutdown) for // a session that may genuinely never resolve. pollTimer.unref?.() @@ -282,7 +92,14 @@ function subscribeViaResolvePoll( } let result: NativeChatTranscriptSubscription | null try { - result = await attemptInstall(args, decode) + result = exactPath ? await attemptInstall({ ...args, filePath: exactPath }, decode) : null + if ( + !result && + (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) + ) { + lastFallbackResolveAt = Date.now() + result = await attemptInstall(args, decode) + } } catch { // Why: a transient resolve failure (EACCES/EIO during the glob) must not // kill the poll loop with an unhandled rejection — retry like a miss. @@ -303,6 +120,7 @@ function subscribeViaResolvePoll( scheduleAttempt() return { + watching: true, unsubscribe: () => { if (closed) { return @@ -334,16 +152,16 @@ function subscribeViaResolvePoll( export async function subscribeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs ): Promise { - const decode = lineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } // Why: a blank session id (and no explicit file) can never resolve — bail out // instead of resolve-polling an unresolvable target forever. if (!args.filePath && !args.sessionId.trim()) { - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } const installed = await attemptInstall(args, decode) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 57153e31fef..acde5dd02c3 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -21,6 +21,7 @@ import type { ProjectGroup, ProjectHostSetup, Repo, + GlobalSettings, TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, @@ -701,6 +702,72 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('defaults the menu bar icon on regardless of platform', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + + await withPlatform('linux', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('enables the menu bar icon when an existing macOS profile has no stored value', async () => { + await withPlatform('darwin', async () => { + const persisted = getDefaultPersistedState(testState.dir) + delete (persisted.settings as Partial).showMenuBarIcon + writeDataFile(persisted) + + const store = await createStore() + + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('persists an explicit macOS menu bar opt-out', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + + it('normalizes menu bar icon writes to a strict boolean', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean }) + expect(store.getSettings().showMenuBarIcon).toBe(false) + store.updateSettings({ showMenuBarIcon: true }) + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('round-trips a macOS menu bar opt-out through a non-mac host unchanged', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + }) + + // Why: a profile opened on another OS must not rewrite the mac preference + // on its next flush; only the darwin consumers act on the value. + await withPlatform('win32', async () => { + const store = await createStore() + store.updateSettings({ minimizeToTrayOnClose: true }) + store.flush() + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + }) + + await withPlatform('darwin', async () => { + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { const store = await createStore() expect(store.getUI().trayMinimizeNoticeShown).toBe(false) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0a2d7821f91..a237e941150 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3200,6 +3200,9 @@ export class Store { // Why: persisted settings can be user-edited or written by older // builds; keep tray-minimize false unless the stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on, and the value must round-trip + // unchanged on non-mac hosts; the darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, @@ -5265,6 +5268,9 @@ export class Store { if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true } + if ('showMenuBarIcon' in updates) { + sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 13ab2846baa..55659b18c20 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -497,16 +497,18 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(['src/index.ts', 'package.json']) }) - it('listFiles forwards excludePaths when provided', async () => { + it('listFiles forwards supported list options', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', { rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }, { signal: undefined } ) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 507bfd98fdb..b17b3c4f783 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -293,12 +293,15 @@ export class SshFilesystemProvider implements IFilesystemProvider { async listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } + if (options?.maxResults !== undefined) { + params.maxResults = options.maxResults + } // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index fa694433616..6dc7b6ec1a3 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -286,7 +286,7 @@ export type IFilesystemProvider = { search(opts: SearchOptions): Promise listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise scanWorkspaceSpace?( rootPath: string, diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 6213765bb20..b10a268ac79 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -193,6 +193,87 @@ describe('mobile presence lock — driver state machine', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) + it('restores the pre-write driver after overlapping claims from one phone both fail', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const second = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + first.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + second.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('keeps a successful overlapping claim as the rollback baseline', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const successful = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const rejected = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + await successful.commit() + rejected.rollback() + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('does not let an older phone-fit completion retake a newer writer floor', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + let releaseFirstLayout!: () => void + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstLayout = () => resolve(true) + }) + ) + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const firstCommit = first.commit() + await vi.waitFor(() => expect(releaseFirstLayout).toBeTypeOf('function')) + + const second = runtime.beginMobileInputFloor('pty-1', 'phone-B')! + await second.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + releaseFirstLayout() + await firstCommit + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('mobile input without an active subscriber cannot create an orphaned floor lock', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(250) + + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('admits a soft-leaving client to reserve the input floor within the grace window', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Inside the soft-leave grace a late write still reserves and commits the floor. + const claim = runtime.beginMobileInputFloor('pty-1', 'phone-A') + expect(claim).not.toBeNull() + await claim!.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Past the grace the client is fully gone and is rejected. + await vi.advanceTimersByTimeAsync(250) + expect(runtime.beginMobileInputFloor('pty-1', 'phone-A')).toBeNull() + }) + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f261e98dbe3..5af3d0d22a3 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -81,9 +81,16 @@ import { } from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { + rankRuntimeMobileFilePaths, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 +const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 +const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 @@ -408,6 +415,10 @@ export type RuntimeFileCommandHost = { export class RuntimeFileCommands { private activeRuntimeTextSearches = new Map() private terminalFileGrants = new Map() + private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) constructor(private readonly host: RuntimeFileCommandHost) {} @@ -437,6 +448,52 @@ export class RuntimeFileCommands { } } + async searchMobileFilePaths( + worktreeSelector: string, + query: string, + limit: number + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` + const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { + const listed = connectionId + ? await this.listRemoteMobileFiles( + worktree.path, + connectionId, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + : await listQuickOpenFiles( + worktree.path, + store, + undefined, + undefined, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + const safePaths = listed + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + return { + paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), + totalCount: safePaths.length, + truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + } + }) + const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: matches.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: matches.totalCount, + truncated: inventory.truncated || matches.totalCount > limit + } + } + async openMobileFile( worktreeSelector: string, relativePath: string @@ -1730,12 +1787,16 @@ export class RuntimeFileCommands { } } - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + private async listRemoteMobileFiles( + rootPath: string, + connectionId: string, + maxResults?: number + ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } - return provider.listFiles(rootPath) + return provider.listFiles(rootPath, { maxResults }) } private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 50f196f2669..15a59321409 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9652,6 +9652,8 @@ describe('OrcaRuntimeService', () => { it('keeps ordinary terminal send suffix failures on the existing not-writable contract', async () => { const writes: string[] = [] + const beforeWrite = vi.fn() + const afterWrite = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), @@ -9687,9 +9689,15 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals await expect( - runtime.sendTerminal(terminal.handle, { text: 'notes', enter: true }) + runtime.sendTerminal( + terminal.handle, + { text: 'notes', enter: true }, + { beforeWrite, afterWrite } + ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) + expect(beforeWrite).toHaveBeenCalledTimes(2) + expect(afterWrite).toHaveBeenCalledOnce() }) it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index da1bbfbe603..e64143647ed 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2409,6 +2409,15 @@ export class OrcaRuntimeService { // `applyMobileDisplayMode` to pick the active phone-fit viewport. See // docs/mobile-presence-lock.md. private currentDriver = new Map() + private mobileInputFloorClaims = new Map< + string, + { + base: DriverState + generation: number + committedGeneration: number + pending: Map + } + >() private currentBrowserDriver = new Map() // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by @@ -5466,6 +5475,8 @@ export class OrcaRuntimeService { listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( this.fileCommands ) + searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = + this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) @@ -9145,18 +9156,108 @@ export class OrcaRuntimeService { this.setDriver(ptyId, { kind: 'mobile', clientId }) } + beginMobileInputFloor( + ptyId: string, + clientId: string + ): { commit: () => Promise; rollback: () => void } | null { + // Why: admit a client still inside its soft-leave grace (mirrors + // mobileTookFloor) so a write landing in that window reserves the floor + // instead of being dropped; post-grace/orphaned writers stay rejected. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { + return null + } + const state = this.mobileInputFloorClaims.get(ptyId) ?? { + base: this.getDriver(ptyId), + generation: 0, + committedGeneration: 0, + pending: new Map() + } + this.mobileInputFloorClaims.set(ptyId, state) + const token = Symbol('mobile-input-floor') + const generation = ++state.generation + state.pending.set(token, { clientId, generation }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + let settled = false + return { + commit: async () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + // Why: a newer accepted write owns the floor; an older claim that was + // delayed before commit must not replace its rollback baseline or driver. + if (generation < state.committedGeneration) { + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + return + } + const previousFloor = state.base + // Why: a successful write becomes the rollback baseline for any + // overlapping reservations that have not reached the PTY yet. + state.committedGeneration = generation + state.base = { kind: 'mobile', clientId } + await this.mobileTookFloor( + ptyId, + clientId, + previousFloor, + () => + this.mobileInputFloorClaims.get(ptyId) === state && + state.committedGeneration === generation + ) + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + }, + rollback: () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + if (this.mobileInputFloorClaims.get(ptyId) !== state) { + return + } + const current = this.getDriver(ptyId) + if (current.kind === 'mobile' && current.clientId === clientId) { + const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId + this.setDriver( + ptyId, + pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base + ) + } + if (state.pending.size === 0) { + this.mobileInputFloorClaims.delete(ptyId) + } + } + } + } + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / // resizeForClient / fresh subscribe with auto). Records the actor as the // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - async mobileTookFloor(ptyId: string, clientId: string): Promise { + async mobileTookFloor( + ptyId: string, + clientId: string, + previousFloor?: DriverState, + isCurrent: () => boolean = () => true + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) + const softLeaver = this.pendingSoftLeavers.get(ptyId) + // Why: native chat pauses terminal output, so its later sends have no + // subscriber lifecycle that could release a newly-created desktop lock. + if (!sub && softLeaver?.clientId !== clientId) { + return + } if (sub) { sub.lastActedAt = Date.now() } - const prev = this.getDriver(ptyId) + const prev = previousFloor ?? this.getDriver(ptyId) const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier @@ -9168,6 +9269,11 @@ export class OrcaRuntimeService { } await this.applyMobileDisplayMode(ptyId) } + // Why: display changes are async; a later PTY write must keep the floor + // when an older phone-fit operation eventually completes. + if (!isCurrent()) { + return + } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -10819,6 +10925,8 @@ export class OrcaRuntimeService { }, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11231,6 +11339,8 @@ export class OrcaRuntimeService { payload: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11248,6 +11358,7 @@ export class OrcaRuntimeService { } try { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) @@ -11258,6 +11369,7 @@ export class OrcaRuntimeService { if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await options.afterWrite?.(ptyId) return } if (hasText) { @@ -11265,10 +11377,12 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) } private async writeTerminalInputChunks( @@ -11276,16 +11390,20 @@ export class OrcaRuntimeService { text: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise } = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts new file mode 100644 index 00000000000..cd47aa8fd7c --- /dev/null +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +describe('file path search RPC method', () => { + it('returns a bounded server-side result for mobile autocomplete', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchMobileFilePaths: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }], + totalCount: 1, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'files.searchPaths', + params: { worktree: 'id:wt-1', query: 'app', limit: 8 } + } + + const response = await dispatcher.dispatch(request) + + expect(runtime.searchMobileFilePaths).toHaveBeenCalledWith('id:wt-1', 'app', 8) + expect(response).toMatchObject({ + ok: true, + result: { files: [{ relativePath: 'src/app.ts' }] } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index a46d24eb1b1..b9af390152b 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -19,6 +19,11 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(256).default(''), + limit: z.number().int().positive().max(32).default(16) +}) + const FileOpen = WorktreeSelector.extend({ relativePath: z .unknown() @@ -176,6 +181,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) }), + defineMethod({ + name: 'files.searchPaths', + params: FilePathSearch, + handler: async (params, { runtime }) => + runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + }), defineMethod({ name: 'files.open', params: FileOpen, diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 720ce127d2c..15bb6b3a24a 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' -// Stub the shared cache so the handler returns a deterministic transcript with +// Stub the bounded tail reader so the handler returns a deterministic transcript with // one oversized tool-result block; the test then asserts clip behavior per client. const OVERSIZED = 'x'.repeat(5000) const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) -vi.mock('../../../native-chat/transcript-read-cache', () => ({ - readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +const watcher = vi.hoisted(() => ({ + args: null as null | { + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + error?: string + ) => void + onAppend: (messages: NativeChatMessage[]) => void + }, + watching: true +})) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: ({ limit }: { limit: number }) => { + const messages = cachedResult.value.messages + return Promise.resolve({ + messages: messages.slice(-limit), + hasMore: messages.length > limit, + beforeOffset: 123 + }) + }, + subscribeNativeChatTranscript: (args: NonNullable) => { + watcher.args = args + return Promise.resolve({ unsubscribe: vi.fn(), watching: watcher.watching }) + } })) import { NATIVE_CHAT_METHODS } from './native-chat' @@ -30,6 +53,34 @@ function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise Promise } +function subscribeHandler(): ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === 'nativeChat.subscribe') + if (!method) { + throw new Error('subscribe method not registered') + } + return method.handler as ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void + ) => Promise +} + +function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { + return { + runtime: { + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } as unknown as RpcContext['runtime'], + connectionId: 'connection-1', + clientKind + } +} + function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { return { runtime: {} as RpcContext['runtime'], clientKind } } @@ -40,6 +91,13 @@ function firstOutput(result: unknown): string { return block.output } +function activeWatcherArgs(): NonNullable { + if (!watcher.args) { + throw new Error('native-chat transcript watcher was not subscribed') + } + return watcher.args +} + describe('nativeChat.readSession clientKind truncation gating', () => { it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } @@ -52,6 +110,131 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(output).toContain('truncated') }) + it('bounds raw tool-call inputs before sending them to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { file_path: 'src/a.ts', content: OVERSIZED } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const input = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(JSON.stringify(input).length).toBeLessThan(OVERSIZED.length) + expect(JSON.stringify(input)).toContain('truncated') + }) + + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + } + ] + } + ] + } + + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(block).toMatchObject({ + type: 'tool-call', + input: { + questions: [ + { + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + }) + }) + + it('bounds tool-call keys and primitive node fanout', async () => { + const wide = Object.fromEntries( + Array.from({ length: 200 }, (_unused, index) => [`${'k'.repeat(200)}-${index}`, index]) + ) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [{ type: 'tool-call', name: 'Write', input: wide }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const encoded = JSON.stringify((result as { messages: NativeChatMessage[] }).messages[0]) + + expect(encoded.length).toBeLessThan(10_000) + expect(encoded).toContain('truncated') + }) + + it('keeps sibling tool-call keys that share a 128-char prefix distinct', async () => { + const prefix = 'p'.repeat(128) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { [`${prefix}A`]: 'first', [`${prefix}B`]: 'second' } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + input: Record + } + const keys = Object.keys(block.input) + + expect(keys).toHaveLength(2) + expect(new Set(keys).size).toBe(2) + expect(Object.values(block.input)).toEqual(expect.arrayContaining(['first', 'second'])) + }) + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -98,5 +281,102 @@ describe('nativeChat.readSession clientKind truncation gating', () => { // Tail-only: the last id survives, the first is dropped. expect(messages.at(-1)?.id).toBe('m-59') expect(messages[0].id).toBe('m-20') + expect(result).toMatchObject({ hasMore: true, beforeOffset: 123 }) + }) +}) + +describe('nativeChat.subscribe initial snapshot', () => { + it('emits one windowed snapshot with pagination state before live appends', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + const messages = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('small'), + id: `m-${index}` + })) + + const callbacks = activeWatcherArgs() + callbacks.onInitialSnapshot?.(messages, true, 123) + const live = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('live'), + id: `m-live-${index}` + })) + callbacks.onAppend(live) + + expect(emitted[0]).toMatchObject({ + type: 'snapshot', + hasMore: true, + beforeOffset: 123 + }) + const snapshotMessages = (emitted[0] as { messages: NativeChatMessage[] }).messages + expect(snapshotMessages).toHaveLength(40) + expect(snapshotMessages[0].id).toBe('m-20') + expect(emitted[1]).toMatchObject({ type: 'appended' }) + expect((emitted[1] as { messages: NativeChatMessage[] }).messages).toHaveLength(60) + }) + + it('emits an explicit error snapshot when no transcript can be watched', async () => { + watcher.watching = false + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 'missing' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + expect(emitted).toEqual([ + { type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' } + ]) + }) + + it('forwards an initial-drain error onto the snapshot frame', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([], false, 0, 'Transcript unavailable') + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [], + hasMore: false, + beforeOffset: 0, + error: 'Transcript unavailable' + } + ]) + }) + + it('omits error from the snapshot frame on a clean initial drain', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([makeMessage('hi')], false, 7) + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [expect.objectContaining({ id: 'a-1' })], + hasMore: false, + beforeOffset: 7 + } + ]) }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05558c8b76c..513a2134011 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,8 +1,10 @@ import { z } from 'zod' import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' import type { AgentType } from '../../../../shared/native-chat-types' -import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' -import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The @@ -39,7 +41,8 @@ const NativeChatSession = z.object({ // Authoritative transcript path from the agent hook (providerSession), used to // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional() + transcriptPath: z.string().min(1).optional(), + beforeOffset: z.number().int().nonnegative().optional() }) const NativeChatUnsubscribe = z.object({ @@ -59,6 +62,8 @@ const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 // The mobile view only previews block bodies, so truncate them on the wire to // keep the payload small; the marker tells the user content was clipped. const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 const TRUNCATION_MARKER = '\n… (truncated)' function clip(text: string): string { @@ -76,13 +81,77 @@ function clipBlock(block: NativeChatBlock): NativeChatBlock { ? { ...block, output: clip(block.output) } : block } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } return block } +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { return { ...message, blocks: message.blocks.map(clipBlock) } } +function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return clientKind === 'mobile' ? messages.map(sanitizeMessage) : messages.slice() +} + /** Window a transcript to its most recent `limit` messages so a long session * can't freeze the client. Windowing by count applies to ALL RPC clients — * shipping thousands of turns over the paired link is bad for web and mobile @@ -113,14 +182,20 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ name: 'nativeChat.readSession', params: NativeChatSession, handler: async (params, { clientKind }) => { - const result = await readNativeChatTranscriptCached( - params.agent, - params.sessionId, - params.transcriptPath - ) - // Window to the conversation tail (all clients); clip blocks for mobile only. + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const result = await readNativeChatTranscriptTail({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + limit, + beforeOffset: params.beforeOffset + }) return 'messages' in result - ? { messages: windowForClient(result.messages, clientKind, params.limit) } + ? { + messages: windowForClient(result.messages, clientKind, limit), + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } : result } }), @@ -130,10 +205,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, clientKind }, emit) => { let closed = false let unsubscribe = (): void => {} - // Why: the subscriber seeds its read offset at 0, so the first drain emits - // the whole transcript and later drains emit only appended turns. The first - // batch is windowed to the tail (a full transcript would freeze mobile); - // later incremental batches are smaller than the window so they pass through. + // Why: the first drain is a bounded tail snapshot; later drains emit only + // appended turns. This avoids parsing or shipping full long transcripts. // Clients merge by message id, so the initial windowed batch doubles as the // snapshot. Keyed by the client-supplied subscriptionId when present so // registration and unsubscribe derive from the same token; otherwise by @@ -141,6 +214,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ // unsubscribe (no wire break). const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -157,11 +231,37 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + if (closed) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + emit({ + type: 'snapshot', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset, + ...(error ? { error } : {}) + }) + }, + onReplace: (messages, hasMore, beforeOffset) => { + if (closed) { + return + } + emit({ + type: 'replacement', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset + }) + }, onAppend: (messages) => { if (closed) { return } - emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind) }) } }) // The connection may have closed while the file was being resolved. @@ -169,6 +269,9 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ subscription.unsubscribe() return } + if (!subscription.watching) { + emit({ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }) + } unsubscribe = subscription.unsubscribe } }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ec85e873b9f..6d5879d9cf8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -335,6 +335,61 @@ function resolveMobileFloorClientId( return null } +async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + await runtime.sendTerminal(args.terminal, action) + return + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + } + } catch { + floorClaim.current?.rollback() + } +} + +type MobileInputFloorClaimHolder = { + current: ReturnType +} + +async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next chunk/suffix, so that write + // needs a fresh reservation if desktop reclaimed the floor meanwhile. + if (claim.current === current) { + claim.current = null + } + } +} + function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { const message = error instanceof Error ? error.message : String(error) if (message.includes('terminal_guard_permission')) { @@ -946,7 +1001,8 @@ const TerminalSubscribe = TerminalHandle.extend({ capabilities: z .object({ terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional() }) .optional() }) @@ -1265,6 +1321,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined let result try { result = await runtime.sendTerminal( @@ -1274,9 +1343,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ enter: params.enter === true, interrupt: params.interrupt === true }, - { beforeWrite: assertSendPreconditions } + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } ) } catch (error) { + mobileFloorClaim.current?.rollback() const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { @@ -1299,15 +1375,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } throw error } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { - await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) - } return { send: result } } }), @@ -1799,22 +1874,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Mobile already has the higher-priority floor; a rejected desktop // viewport claim must never suppress later phone input. const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) - ? null - : runtime.sendTerminal(stream.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (stream.isMobile && stream.client?.id) { - await runtime.mobileTookFloor(stream.ptyId, stream.client.id) - } + void inputClaimTail.then((claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + return sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { @@ -2451,6 +2521,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const mobileInputLeaseOnly = + isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) // Why: the initial mount/PTY wait and phone-fit can both emit a redraw // that creates suffix-only state, so preserve the pre-mount absence signal. const missingHeadlessStateBeforeMobileFit = @@ -2462,6 +2534,48 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ : runtime.getRendererTerminalSerializerGeneration(ptyId) : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + if (mobileInputLeaseOnly && clientId) { + let closed = false + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor acknowledgement without registering + // a view subscriber or transporting duplicate PTY output. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit', signal }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) + try { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and otherwise + // resurrect mobile presence after cleanup already released it. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + runtime.cleanupSubscription(subscriptionId) + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } + return + } // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). let registeredRemoteDesktopDriver = false @@ -2675,22 +2789,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void desktopClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) - ? null - : runtime.sendTerminal(params.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (isMobile && clientId) { - await runtime.mobileTookFloor(ptyId, clientId) - } + void desktopClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 20b608c94c2..99a77fb9afd 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -692,6 +692,9 @@ describe('terminal multiplex RPC', () => { }) => void) | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback: vi.fn() })) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -723,7 +726,13 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ @@ -787,12 +796,16 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'x', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'x', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) binaryFrames.splice(0) resizeListener?.({ diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index d10456c8fa6..c1ee755ee83 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -277,12 +277,18 @@ describe('terminal output batching', () => { } }) - it('routes binary terminal input frames back to the subscribed PTY', async () => { + it('claims the mobile floor before binary terminal input and rolls back rejection', async () => { const handlers = new Map< number, (frame: NonNullable>) => void >() const cleanups = new Map void>() + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn( + (): ReturnType => ({ commit, rollback }) + ) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -293,7 +299,11 @@ describe('terminal output batching', () => { subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -302,7 +312,13 @@ describe('terminal output batching', () => { cleanups.delete(id) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue(false) }) const dispatcher = new RpcDispatcher({ @@ -314,7 +330,7 @@ describe('terminal output batching', () => { const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1', - client: { id: 'desktop-1', type: 'desktop' }, + client: { id: 'mobile-1', type: 'mobile' }, capabilities: { terminalBinaryStream: 1 } }), (msg) => messages.push(msg), @@ -345,14 +361,54 @@ describe('terminal output batching', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'ls\r', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) + + vi.mocked(runtime.sendTerminal).mockImplementationOnce(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + return { handle: 'terminal-1', accepted: false, bytesWritten: 0 } + }) + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 2, + payload: encodeTerminalStreamText('rejected') + }) + )! + ) + await vi.waitFor(() => expect(rollback).toHaveBeenCalledOnce()) + expect(commit).toHaveBeenCalledOnce() + + // Post-unsubscribe: capture the still-registered handler, tear down the + // subscription, then replay a frame. The closed guard must drop it, so no + // further sendTerminal dispatch and no write reach the PTY. + const staleHandler = handlers.get(streamId)! + runtime.cleanupSubscription('terminal-1:mobile-1') + staleHandler( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 3, + payload: encodeTerminalStreamText('after-unsubscribe') + }) + )! + ) + await Promise.resolve() + expect(runtime.sendTerminal).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() - runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise }) }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 58b37bcd7fc..e9fb481aae0 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -16,6 +16,12 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + beginMobileInputFloor: vi.fn((ptyId: string, clientId: string) => ({ + commit: async () => { + await overrides.mobileTookFloor?.(ptyId, clientId) + }, + rollback: vi.fn() + })), ...overrides } as OrcaRuntimeService } @@ -144,10 +150,9 @@ describe('terminal send RPC', () => { releaseClaim = () => resolve(true) }) ) - const sendTerminal = vi.fn().mockResolvedValue({ - handle: 'terminal-1', - accepted: true, - bytesWritten: 1 + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -177,15 +182,33 @@ describe('terminal send RPC', () => { }) it('accepts legacy clientless mobile input when the current driver is mobile', async () => { - const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - sendTerminal: vi.fn().mockResolvedValue({ + const write = vi.fn() + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 - }), - mobileTookFloor: vi.fn().mockResolvedValue(undefined) + } + }) + const mobileTookFloor = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ + commit: async () => mobileTookFloor('pty-1', 'mobile-1'), + rollback: vi.fn() + })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal, + beginMobileInputFloor, + mobileTookFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -208,9 +231,115 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { + beforeWrite: undefined, + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(runtime.mobileTookFloor).toHaveBeenCalledTimes(2) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan( + mobileTookFloor.mock.invocationCallOrder[0]! + ) + }) + + it.each([ + ['rejects', async () => ({ handle: 'terminal-1', accepted: false, bytesWritten: 0 })], + [ + 'throws', + async () => { + throw new Error('write failed') + } + ] + ])('rolls back a mobile floor claim when the terminal write %s', async (_case, finishWrite) => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback })) + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + return finishWrite() + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal, + beginMobileInputFloor + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() + expect(commit).not.toHaveBeenCalled() + }) + + it('writes zero bytes when the mobile subscriber disappeared before reservation', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + beginMobileInputFloor: vi.fn().mockReturnValue(null), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { terminal: 'terminal-1', text: 'stale' }) + ) + + expect(response.ok).toBe(false) + expect(write).not.toHaveBeenCalled() + }) + + it('reclaims the mobile floor for a delayed suffix and preserves the earlier commit', async () => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit: async () => commit(), rollback })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + beginMobileInputFloor, + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + await options.afterWrite('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + throw new Error('suffix failed') + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'partial', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(commit).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() }) it('writes a validated terminal query reply without taking the mobile floor', async () => { @@ -446,6 +575,7 @@ describe('terminal send RPC', () => { }) it('refuses guarded terminal sends when the agent needs permission', async () => { + const beginMobileInputFloor = vi.fn() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), @@ -454,7 +584,8 @@ describe('terminal send RPC', () => { isRunningAgent: true, status: 'permission' }), - sendTerminal: vi.fn() + sendTerminal: vi.fn(), + beginMobileInputFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -463,7 +594,7 @@ describe('terminal send RPC', () => { terminal: 'terminal-1', enter: true, requireAgentStatus: 'sendable', - client: { id: 'desktop-1', type: 'desktop' } + client: { id: 'mobile-1', type: 'mobile' } }) ) @@ -481,6 +612,7 @@ describe('terminal send RPC', () => { }) expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(beginMobileInputFloor).not.toHaveBeenCalled() }) it('allows guarded terminal sends when the agent is sendable', async () => { diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts new file mode 100644 index 00000000000..fab3d5a09cd --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } +} + +describe('terminal lease-only subscription', () => { + it('keeps mobile input ownership without registering output delivery', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(), + readTerminal: vi.fn(), + serializeTerminalBuffer: vi.fn(), + subscribeToTerminalResize: vi.fn(), + subscribeToFitOverrideChanges: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + request, + (message) => messages.push(message), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + expect(runtime.handleMobileSubscribe).toHaveBeenCalledWith('pty-1', 'phone-1', undefined) + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.registerRemoteTerminalViewSubscriber).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalResize).not.toHaveBeenCalled() + expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts new file mode 100644 index 00000000000..495a7933842 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + rankRuntimeMobileFilePaths, + type RuntimeMobileFilePathInventory, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' + +describe('rankRuntimeMobileFilePaths', () => { + it('ranks path and basename prefixes before substrings and caps output', () => { + expect( + rankRuntimeMobileFilePaths( + ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], + 'apple', + 3 + ) + ).toEqual({ + paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], + totalCount: 4 + }) + }) +}) + +describe('RuntimeMobileFilePathSearchCache', () => { + it('reuses a live entry, reloads after expiry, and evicts the oldest key', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ + paths: [path], + totalCount: 1, + truncated: false + })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-again'), 50) + await cache.get('b', () => load('b'), 50) + await cache.get('c', () => load('c'), 50) + await cache.get('a', () => load('a-reloaded'), 50) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'b', 'c', 'a-reloaded']) + }) + + it('reloads an unevicted key once its TTL has elapsed', async () => { + // TTL expiry without capacity pressure — exercises the now >= expiresAt path + // that LRU eviction otherwise masks. + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ paths: [path], totalCount: 1, truncated: false })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-live'), 50) + await cache.get('a', () => load('a-expired'), 150) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'a-expired']) + }) + + it('coalesces concurrent cold loads for the same worktree', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + + const first = cache.get('a', load, 0) + const second = cache.get('a', load, 0) + expect(load).toHaveBeenCalledOnce() + + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await expect(Promise.all([first, second])).resolves.toEqual([ + { paths: ['a'], totalCount: 1, truncated: false }, + { paths: ['a'], totalCount: 1, truncated: false } + ]) + }) + + it('starts the TTL when a slow inventory becomes usable', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + const first = cache.get('slow-ssh', load) + vi.setSystemTime(200) + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await first + + await cache.get('slow-ssh', load) + expect(load).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts new file mode 100644 index 00000000000..13213d6dfcf --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -0,0 +1,93 @@ +export type RuntimeMobileFilePathInventory = { + paths: string[] + totalCount: number + truncated: boolean +} + +type CacheEntry = RuntimeMobileFilePathInventory & { expiresAt: number } + +/** Lazy TTL/LRU cache for autocomplete inventories. It avoids launching rg for + * every mobile keystroke while bounding retained worktrees and paths. */ +export class RuntimeMobileFilePathSearchCache { + private readonly entries = new Map() + private readonly inFlight = new Map>() + + constructor( + private readonly maxEntries: number, + private readonly ttlMs: number + ) {} + + async get( + key: string, + load: () => Promise, + now?: number + ): Promise { + const requestedAt = now ?? Date.now() + const cached = this.entries.get(key) + if (cached && cached.expiresAt > requestedAt) { + this.entries.delete(key) + this.entries.set(key, cached) + return cached + } + this.entries.delete(key) + const pending = this.inFlight.get(key) + if (pending) { + return pending + } + const next = load() + .then((loaded) => { + // Why: a slow SSH scan should receive a full TTL after it becomes usable, + // not arrive already expired because the clock started before its I/O. + this.entries.set(key, { ...loaded, expiresAt: (now ?? Date.now()) + this.ttlMs }) + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string | undefined + if (!oldest) { + break + } + this.entries.delete(oldest) + } + return loaded + }) + .finally(() => { + if (this.inFlight.get(key) === next) { + this.inFlight.delete(key) + } + }) + // Why: debounced clients can overlap on a cold key; sharing this promise + // prevents duplicate local rg or SSH inventory scans. + this.inFlight.set(key, next) + return next + } +} + +/** Preserves composer ranking: full-path/basename prefixes first, then substring + * matches, while returning only the requested bounded candidate slice. */ +export function rankRuntimeMobileFilePaths( + paths: readonly string[], + query: string, + limit: number +): { paths: string[]; totalCount: number } { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return { paths: paths.slice(0, limit), totalCount: paths.length } + } + const prefix: string[] = [] + const substring: string[] = [] + let totalCount = 0 + for (const path of paths) { + const lower = path.toLowerCase() + const basename = lower.split('/').pop() ?? lower + if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { + totalCount++ + if (prefix.length < limit) { + prefix.push(path) + } + } else if (lower.includes(normalizedQuery)) { + totalCount++ + if (substring.length < limit) { + substring.push(path) + } + } + } + return { paths: [...prefix, ...substring].slice(0, limit), totalCount } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6dd4856b738..d7623065634 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -199,6 +199,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', + 'files.searchPaths', 'files.writeTerminalArtifact', 'folderWorkspace.list', 'git.abortMerge', diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index 9f5ad72caad..71c94f84e74 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -2,19 +2,60 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' const { - trayInstances, - menuFromTemplateMock, - createAppIconImageMock, + attentionImage, + baseMacImage, composeAttentionMock, - resizedImage + createAppIconImageMock, + createFromPathMock, + menuFromTemplateMock, + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock, + trayInstances } = vi.hoisted(() => { - const resizedImage = { resized: true } + const makeImage = (name: string) => ({ + name, + addRepresentation: vi.fn(), + getScaleFactors: vi.fn(() => [1, 2]), + getSize: vi.fn(() => ({ width: 16, height: 16 })), + setTemplateImage: vi.fn(), + toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)), + toDataURL: vi.fn(() => `data:image/png;base64,${name}`) + }) + const resizedImage = makeImage('windows') + const baseMacImage = makeImage('mac-base') + const retinaMacImage = makeImage('mac-retina') + const tintedMacImage = makeImage('mac-tinted') + const attentionImage = makeImage('attention') + const themeState = { updatedListener: null as (() => void) | null } + const nativeThemeMock = { + shouldUseDarkColors: false, + on: vi.fn((_event: string, listener: () => void) => { + themeState.updatedListener = listener + }), + removeListener: vi.fn((_event: string, listener: () => void) => { + if (themeState.updatedListener === listener) { + themeState.updatedListener = null + } + }) + } return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + attentionImage, + baseMacImage, + composeAttentionMock: vi.fn(() => attentionImage), createAppIconImageMock: vi.fn(), - composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), - resizedImage + createFromPathMock: vi.fn(), + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock: vi.fn(() => tintedMacImage), + trayInstances: [] as FakeTray[] } }) @@ -32,7 +73,17 @@ class FakeTray { vi.mock('electron', () => ({ Tray: FakeTray, - Menu: { buildFromTemplate: menuFromTemplateMock } + Menu: { buildFromTemplate: menuFromTemplateMock }, + nativeImage: { createFromPath: createFromPathMock }, + nativeTheme: nativeThemeMock +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate.png' +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate@2x.png' })) vi.mock('../app-icon', () => ({ @@ -40,10 +91,12 @@ vi.mock('../app-icon', () => ({ })) vi.mock('./tray-attention-icon', () => ({ - composeTrayAttentionIcon: composeAttentionMock + composeTrayAttentionIcon: composeAttentionMock, + tintTrayTemplateForAttention: tintTemplateMock })) type TrayModule = typeof SystemTrayModule +type MenuItem = { label?: string; type?: string; click?: () => void } const originalPlatform = process.platform @@ -56,7 +109,15 @@ async function loadModule(): Promise { return import('./system-tray') } -type MenuItem = { label?: string; type?: string; click?: () => void } +function createOptions() { + return { + appIcon: 'classic', + onOpen: vi.fn(), + onOpenSettings: vi.fn(), + onCheckForUpdates: vi.fn(), + onQuit: vi.fn() + } +} function builtMenuItems(): MenuItem[] { return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[] @@ -66,139 +127,289 @@ beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() composeAttentionMock.mockClear() + tintTemplateMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) + createFromPathMock.mockReset() + createFromPathMock.mockImplementation((path: string) => + path.includes('@2x') ? retinaMacImage : baseMacImage + ) + for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) { + image.addRepresentation.mockClear() + image.getScaleFactors.mockReset().mockReturnValue([1, 2]) + image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 }) + image.setTemplateImage.mockClear() + image.toBitmap.mockClear() + image.toDataURL.mockClear().mockReturnValue(`data:image/png;base64,${image.name}`) + } + nativeThemeMock.shouldUseDarkColors = false + nativeThemeMock.on.mockClear() + nativeThemeMock.removeListener.mockClear() + themeState.updatedListener = null }) afterEach(() => { setPlatform(originalPlatform) + vi.restoreAllMocks() }) describe('createSystemTray', () => { - it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => { + it('keeps the Windows icon, Open/Quit menu, and left-click behavior', async () => { setPlatform('win32') const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + createSystemTray(options) - expect(tray).not.toBeNull() expect(trayInstances).toHaveLength(1) expect(trayInstances[0].image).toBe(resizedImage) expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca') - const items = builtMenuItems() - expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit']) - expect(items[1].type).toBe('separator') + expect(builtMenuItems().map((item) => item.label)).toEqual(['Open Orca', undefined, 'Quit']) + const clickHandler = trayInstances[0].on.mock.calls.find((call) => call[0] === 'click')?.[1] + expect(clickHandler).toBeTypeOf('function') + + builtMenuItems()[0].click?.() + ;(clickHandler as () => void)() + builtMenuItems()[2].click?.() + expect(options.onOpen).toHaveBeenCalledTimes(2) + expect(options.onQuit).toHaveBeenCalledOnce() }) - it('wires Open Orca, the tray click, and Quit to their callbacks', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() - - createSystemTray({ appIcon: 'classic', onOpen, onQuit }) - - const items = builtMenuItems() - items.find((i) => i.label === 'Open Orca')?.click?.() - expect(onOpen).toHaveBeenCalledTimes(1) - - const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as - | (() => void) - | undefined - clickHandler?.() - expect(onOpen).toHaveBeenCalledTimes(2) - - items.find((i) => i.label === 'Quit')?.click?.() - expect(onQuit).toHaveBeenCalledTimes(1) - }) - - it('is idempotent: a second call does not create a duplicate tray', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } - - const first = createSystemTray(opts) - const second = createSystemTray(opts) - - expect(trayInstances).toHaveLength(1) - expect(second).toBe(first) - }) - - it('is a no-op on non-win32 platforms', async () => { + it('creates a macOS template status item with the full native menu', async () => { setPlatform('darwin') const { createSystemTray } = await loadModule() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(options) - expect(tray).toBeNull() + expect(trayInstances).toHaveLength(1) + expect(trayInstances[0].image).toBe(baseMacImage) + expect(baseMacImage.setTemplateImage).toHaveBeenCalledWith(true) + expect(baseMacImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,mac-retina' + }) + expect(builtMenuItems().map((item) => item.label)).toEqual([ + 'Open Orca', + undefined, + 'Settings', + 'Check for Updates...', + undefined, + 'Quit' + ]) + expect(trayInstances[0].on).not.toHaveBeenCalled() + expect(nativeThemeMock.on).toHaveBeenCalledWith('updated', expect.any(Function)) + + for (const [label, callback] of [ + ['Open Orca', options.onOpen], + ['Settings', options.onOpenSettings], + ['Check for Updates...', options.onCheckForUpdates], + ['Quit', options.onQuit] + ] as const) { + builtMenuItems() + .find((item) => item.label === label) + ?.click?.() + expect(callback).toHaveBeenCalledOnce() + } + }) + + it('does not create a blank macOS item when the template asset fails to load', async () => { + setPlatform('darwin') + baseMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).toBeNull() expect(trayInstances).toHaveLength(0) + expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded') + }) + + it('is idempotent and remains disabled on Linux', async () => { + setPlatform('darwin') + const { createSystemTray } = await loadModule() + const options = createOptions() + expect(createSystemTray(options)).toBe(createSystemTray(options)) + expect(trayInstances).toHaveLength(1) + + setPlatform('linux') + const linuxModule = await loadModule() + expect(linuxModule.createSystemTray(options)).toBeNull() + }) +}) + +describe('macOS visibility', () => { + it('creates and destroys the item as the setting toggles', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible } = await loadModule() + const options = createOptions() + + expect(setMacMenuBarIconVisible(false, options)).toBeNull() + expect(trayInstances).toHaveLength(0) + expect(setMacMenuBarIconVisible(true, options)).not.toBeNull() + expect(trayInstances).toHaveLength(1) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(false, options) + expect(trayInstances[0].destroy).toHaveBeenCalledOnce() }) }) describe('setTrayAttention', () => { - it('swaps in the dotted icon when active and restores the base when cleared', async () => { + it('preserves the Windows attention swap behavior', async () => { setPlatform('win32') const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() setTrayAttention(true) expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - - tray.setImage.mockClear() + expect(created.setImage).toHaveBeenCalledWith(attentionImage) setTrayAttention(false) - expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + expect(created.setImage).toHaveBeenLastCalledWith(resizedImage) }) - it('ignores repeated same-state calls', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() - - setTrayAttention(true) - setTrayAttention(true) - - expect(tray.setImage).toHaveBeenCalledTimes(1) - }) - - it('reflects attention that was requested before the tray was created', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - - // Fire the event before the (deferred) tray exists. - setTrayAttention(true) - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - }) - - it('is a safe no-op on non-win32 platforms', async () => { + it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => { setPlatform('darwin') - const { setTrayAttention } = await loadModule() + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + created.setToolTip.mockClear() - expect(() => setTrayAttention(true)).not.toThrow() - expect(composeAttentionMock).not.toHaveBeenCalled() + setTrayAttention(true) + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false) + expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage) + // Why: the attention image is rebuilt from 1x pixels, so the @2x + // representation must be re-added or the glyph blurs on Retina. + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false, 2) + expect(attentionImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,attention' + }) + expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false) + expect(created.setImage).toHaveBeenCalledWith(attentionImage) + expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting') + + setTrayAttention(false) + expect(baseMacImage.setTemplateImage).toHaveBeenLastCalledWith(true) + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + }) + + it('recomposes active macOS attention when the system appearance changes', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + setTrayAttention(true) + tintTemplateMock.mockClear() + nativeThemeMock.shouldUseDarkColors = true + + themeState.updatedListener?.() + + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, true) + }) + + it('reflects attention requested before deferred macOS creation', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + setTrayAttention(true) + createSystemTray(createOptions()) + + expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage) + }) + + it('ignores repeated same-state attention calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(created.setImage).toHaveBeenCalledTimes(1) + }) + + it('keeps a pending attention dot across a macOS hide/show toggle', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule() + const options = createOptions() + setMacMenuBarIconVisible(true, options) + setTrayAttention(true) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(true, options) + + expect(trayInstances).toHaveLength(2) + expect(trayInstances[1].setImage).toHaveBeenCalledWith(attentionImage) }) }) describe('destroySystemTray', () => { - it('destroys an existing tray and is safe to call without one', async () => { - setPlatform('win32') + it('destroys idempotently and removes the macOS appearance listener', async () => { + setPlatform('darwin') const { createSystemTray, destroySystemTray } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(createOptions()) const created = trayInstances[0] + const listener = themeState.updatedListener destroySystemTray() - expect(created.destroy).toHaveBeenCalledTimes(1) + destroySystemTray() - // Second call with no live tray must not throw. - expect(() => destroySystemTray()).not.toThrow() + expect(created.destroy).toHaveBeenCalledOnce() + expect(nativeThemeMock.removeListener).toHaveBeenCalledWith('updated', listener) + }) +}) + +describe('macOS hardening', () => { + it('warns but still creates the item when only the @2x asset fails to load', async () => { + setPlatform('darwin') + retinaMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).not.toBeNull() + expect(trayInstances).toHaveLength(1) + expect(baseMacImage.addRepresentation).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS retina menu bar icon could not be loaded' + ) + }) + + it('degrades to the plain icon instead of throwing when attention composition fails', async () => { + setPlatform('darwin') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + composeAttentionMock.mockImplementationOnce(() => { + throw new Error('native image failure') + }) + + expect(() => setTrayAttention(true)).not.toThrow() + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS attention icon failed; showing plain icon', + expect.any(Error) + ) + }) + + it('contains a throwing menu-item callback instead of crashing', async () => { + setPlatform('darwin') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + const options = createOptions() + options.onOpenSettings.mockImplementation(() => { + throw new Error('boom') + }) + createSystemTray(options) + + const settingsItem = builtMenuItems().find((item) => item.label === 'Settings') + expect(() => settingsItem?.click?.()).not.toThrow() + expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error)) }) }) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index ada6f58c4e9..27d2487a3c5 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,13 +1,19 @@ -import { Menu, Tray, type NativeImage } from 'electron' +import { Menu, Tray, nativeImage, nativeTheme, type NativeImage } from 'electron' +import menuBarIconPath from '../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack' +import menuBarIconRetinaPath from '../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' -type SystemTrayOptions = { +export type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ appIcon: unknown /** Restore + show + focus the main window (recreating it if needed). */ onOpen: () => void + /** Restore the main window and open its Settings surface. */ + onOpenSettings: () => void + /** Run the existing user-initiated update check. */ + onCheckForUpdates: () => void /** Quit Orca for real (caller must set the quitting latch before quitting). */ onQuit: () => void } @@ -25,6 +31,8 @@ let baseTrayImage: NativeImage | null = null // freshly created tray reflects it immediately. let attentionActive = false +let nativeThemeUpdatedListener: (() => void) | null = null + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 @@ -35,46 +43,195 @@ function applyTrayImage(): void { if (!tray || tray.isDestroyed() || !baseTrayImage) { return } + + if (process.platform === 'darwin') { + if (attentionActive) { + try { + // Why: disabling template tinting makes the amber dot possible, but the + // glyph then needs literal pixels chosen for the current menu-bar theme. + const useLightGlyph = nativeTheme.shouldUseDarkColors + const attentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph) + ) + if (baseTrayImage.getScaleFactors().includes(2)) { + // Why: toBitmap reads only the 1x pixels, so rebuild the @2x + // representation or the glyph blurs on Retina during attention. + const retinaAttentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph, 2) + ) + attentionImage.addRepresentation({ + scaleFactor: 2, + dataURL: retinaAttentionImage.toDataURL() + }) + } + attentionImage.setTemplateImage(false) + tray.setImage(attentionImage) + tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting')) + return + } catch (error) { + // Why: this path runs inside unguarded callbacks (nativeTheme 'updated', + // the notification bell), so a NativeImage failure must degrade to the + // plain template icon rather than throw an uncaught exception into them. + console.warn('[system-tray] macOS attention icon failed; showing plain icon', error) + } + } + + baseTrayImage.setTemplateImage(true) + tray.setImage(baseTrayImage) + tray.setToolTip('Orca') + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) } +function createMacMenuBarImage(): NativeImage | null { + const image = nativeImage.createFromPath(menuBarIconPath) + const { width, height } = image.getSize() + if (width <= 0 || height <= 0) { + console.warn('[system-tray] macOS menu bar icon could not be loaded') + return null + } + + const retinaImage = nativeImage.createFromPath(menuBarIconRetinaPath) + const retinaSize = retinaImage.getSize() + if (retinaSize.width > 0 && retinaSize.height > 0) { + try { + // Why: importing the @2x asset guarantees it is packaged; adding the + // representation explicitly avoids relying on bundler-renamed siblings. + image.addRepresentation({ + scaleFactor: 2, + dataURL: retinaImage.toDataURL() + }) + } catch (error) { + // Why: a bad @2x representation must not abort tray creation; the 1x image + // still renders (just blurrier on Retina). + console.warn('[system-tray] macOS retina menu bar icon could not be added', error) + } + } else { + // Why: surface the silently-degraded (non-Retina) icon so a blurry menu-bar + // glyph on a modern display is diagnosable, matching the base-image warning. + console.warn('[system-tray] macOS retina menu bar icon could not be loaded') + } + image.setTemplateImage(true) + return image +} + +function watchMacAppearance(): void { + if (nativeThemeUpdatedListener) { + return + } + nativeThemeUpdatedListener = () => { + if (attentionActive) { + applyTrayImage() + } + } + nativeTheme.on('updated', nativeThemeUpdatedListener) +} + +function stopWatchingMacAppearance(): void { + if (!nativeThemeUpdatedListener) { + return + } + nativeTheme.removeListener('updated', nativeThemeUpdatedListener) + nativeThemeUpdatedListener = null +} + +// Why: Electron Menu/Tray click callbacks are plain event listeners (not +// promise-wrapped like ipcMain.handle), so an uncaught throw here is fatal to +// the main process; contain it to a logged, failed menu action instead. +function safeMenuAction(action: () => void): () => void { + return () => { + try { + action() + } catch (error) { + console.error('[system-tray] menu action failed', error) + } + } +} + /** - * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a - * second call while a tray is alive returns the existing one instead of - * stacking a duplicate ghost icon. + * Creates the Windows notification icon or macOS menu bar status item. No-op + * on Linux. Idempotent: repeated calls never stack duplicate icons. */ export function createSystemTray(opts: SystemTrayOptions): Tray | null { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return null } if (tray && !tray.isDestroyed()) { return tray } - baseTrayImage = createAppIconImage(opts.appIcon).resize({ - width: TRAY_ICON_SIZE, - height: TRAY_ICON_SIZE - }) + + if (process.platform === 'darwin') { + baseTrayImage = createMacMenuBarImage() + if (!baseTrayImage) { + return null + } + } else { + baseTrayImage = createAppIconImage(opts.appIcon).resize({ + width: TRAY_ICON_SIZE, + height: TRAY_ICON_SIZE + }) + } + tray = new Tray(baseTrayImage) // Why: reflect any attention event that fired before the tray existed. applyTrayImage() - tray.setToolTip('Orca') + const menu = Menu.buildFromTemplate([ - { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, + { + label: translateMain('tray.openOrca', 'Open Orca'), + click: safeMenuAction(() => opts.onOpen()) + }, { type: 'separator' }, - { label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() } + // Why: reuse the app menu's keys so the two entry points never drift. + ...(process.platform === 'darwin' + ? ([ + { + label: translateMain('menu.settings', 'Settings'), + click: safeMenuAction(() => opts.onOpenSettings()) + }, + { + label: translateMain('menu.checkForUpdates', 'Check for Updates...'), + click: safeMenuAction(() => opts.onCheckForUpdates()) + }, + { type: 'separator' } + ] as Electron.MenuItemConstructorOptions[]) + : []), + { label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) } ]) tray.setContextMenu(menu) - // Why: a left-click on the tray icon is the conventional Windows gesture to - // restore a minimized-to-tray app. - tray.on('click', () => opts.onOpen()) + if (process.platform === 'win32') { + tray.setToolTip('Orca') + // Why: a left-click on the tray icon is the conventional Windows gesture to + // restore a minimized-to-tray app; macOS opens the attached menu instead. + tray.on( + 'click', + safeMenuAction(() => opts.onOpen()) + ) + } else { + watchMacAppearance() + } return tray } +/** Applies the persisted macOS visibility preference without affecting Windows. */ +export function setMacMenuBarIconVisible(visible: boolean, opts: SystemTrayOptions): Tray | null { + if (process.platform !== 'darwin') { + return null + } + if (!visible) { + destroySystemTray() + return null + } + return createSystemTray(opts) +} + /** * Shows or hides a red/amber attention dot on the tray icon. Call with `true` * when a terminal bell or agent completion fires while the window is - * minimized/hidden, and `false` once the window is shown again. No-op on - * macOS/Linux (no tray) and safe to call before the tray is created. + * minimized/hidden, and `false` once the window is shown again. Safe to call + * before the tray is created or on Linux where no tray is available. */ export function setTrayAttention(active: boolean): void { if (attentionActive === active) { @@ -86,10 +243,12 @@ export function setTrayAttention(active: boolean): void { /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { + stopWatchingMacAppearance() if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null baseTrayImage = null - attentionActive = false + // Why: attention is owned by the notification/visibility flow, and must + // survive the macOS hide/show toggle so a re-shown icon keeps its dot. } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts index 42d554417e0..090847e284e 100644 --- a/src/main/tray/tray-attention-icon.test.ts +++ b/src/main/tray/tray-attention-icon.test.ts @@ -12,7 +12,7 @@ vi.mock('electron', () => ({ nativeImage: { createFromBitmap: createFromBitmapMock } })) -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' type FakeImage = { getSize: () => { width: number; height: number } @@ -86,3 +86,59 @@ describe('composeTrayAttentionIcon', () => { expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched }) }) + +describe('tintTrayTemplateForAttention', () => { + it('preserves alpha while selecting literal black pixels for a light menu bar', () => { + createFromBitmapMock.mockClear() + const bitmap = Buffer.from([0x22, 0x33, 0x44, 0x80, 0xaa, 0xbb, 0xcc, 0x00]) + const base = { + getSize: () => ({ width: 2, height: 1 }), + toBitmap: () => bitmap + } + + tintTrayTemplateForAttention(base as never, false) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual( + Buffer.from([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00]) + ) + }) + + it('selects literal white pixels for a dark menu bar', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0xff]) + } + + tintTrayTemplateForAttention(base as never, true) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff])) + }) + + it('keeps white edge pixels premultiplied-valid at partial alpha', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0x80]) + } + + tintTrayTemplateForAttention(base as never, true) + + // Why: the bitmap is premultiplied — white at 50% coverage is 0x80, not 0xff. + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0x80, 0x80, 0x80, 0x80])) + }) + + it('reads and sizes the requested scale factor', () => { + createFromBitmapMock.mockClear() + const toBitmap = vi.fn(() => Buffer.from([0x00, 0x00, 0x00, 0xff])) + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap + } + + tintTrayTemplateForAttention(base as never, true, 2) + + expect(toBitmap).toHaveBeenCalledWith({ scaleFactor: 2 }) + expect(createFromBitmapMock.mock.calls[0][1]).toEqual({ width: 2, height: 2 }) + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts index d9b042db37f..ffdf4528d47 100644 --- a/src/main/tray/tray-attention-icon.ts +++ b/src/main/tray/tray-attention-icon.ts @@ -7,6 +7,36 @@ const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } // A near-white halo separates the dot from the icon glyph on any tray theme. const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } +/** + * Converts a template mask into a literal glyph for the attention state. A + * non-template image needs real RGB values because macOS no longer tints it. + */ +export function tintTrayTemplateForAttention( + base: NativeImage, + useLightGlyph: boolean, + scaleFactor = 1 +): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + const bitmap = Buffer.from(base.toBitmap({ scaleFactor })) + for (let offset = 0; offset < bitmap.length; offset += 4) { + // Why: the bitmap is premultiplied, so a light glyph must write the pixel's + // alpha (not 0xff) or antialiased edges become invalid over-bright pixels. + const channel = useLightGlyph ? bitmap[offset + 3] : 0x00 + bitmap[offset] = channel + bitmap[offset + 1] = channel + bitmap[offset + 2] = channel + } + + return nativeImage.createFromBitmap(bitmap, { + width: width * scaleFactor, + height: height * scaleFactor + }) +} + /** * Returns a copy of `base` with a small amber attention dot composited into the * top-right corner. Electron's NativeImage has no compositing API, so we merge diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2cb87ee388b..a3db4bbbc55 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -833,10 +833,15 @@ export type NativeChatReadSessionResult = /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] +export type NativeChatSubscriptionFrame = + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } + /** Wire payload for the `nativeChat:appended` push channel. */ export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatAppendedMessages + frame: NativeChatSubscriptionFrame } export type NativeChatSubscribeArgs = { @@ -847,6 +852,8 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + /** First snapshot size; later readSession calls grow this for pagination. */ + limit?: number } export type NativeChatApi = { @@ -860,11 +867,11 @@ export type NativeChatApi = { limit?: number, transcriptPath?: string ) => Promise - /** Live-tail a transcript: `onAppended` fires with only newly-appended - * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; + * later frames contain only newly appended messages. */ subscribe: ( args: NativeChatSubscribeArgs, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ) => () => void } @@ -2760,6 +2767,8 @@ export type PreloadApi = { recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void onOpenSettings: (callback: () => void) => () => void + /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ + consumePendingOpenSettings: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index e700e6b06ac..4aa685d7b56 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -177,9 +177,9 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AgentType } from '../shared/native-chat-types' import type { - NativeChatAppendedMessages, NativeChatAppendedPayload, - NativeChatReadSessionResult + NativeChatReadSessionResult, + NativeChatSubscriptionFrame } from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, @@ -3172,6 +3172,8 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -3945,12 +3947,13 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string + limit?: number }, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { if (payload.subscriptionId === args.subscriptionId) { - onAppended(payload.messages) + onFrame(payload.frame) } } ipcRenderer.on('nativeChat:appended', listener) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index ae418f9e881..8de2ad3394c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -9,8 +9,15 @@ import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' -import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' -import { expandQuickOpenGitFileListing } from '../shared/quick-open-readdir-walk' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../shared/quick-open-filter' +import { + expandQuickOpenGitFileListing, + parseQuickOpenGitLsFilesEntry +} from '../shared/quick-open-readdir-walk' import { buildGitGrepArgs, buildSubmatchRegex, @@ -33,19 +40,21 @@ import { buildRelayGitEnv } from './relay-command-env' export function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -53,15 +62,32 @@ export function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded SSH scans must not retain another full listing. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: placeholders need IO classification and can disappear; only + // guaranteed final files are allowed to stop the remote Git processes. + return maxResults !== undefined && directFileCandidates.size >= maxResults } const child = spawn('git', ['ls-files', ...args], { @@ -100,7 +126,8 @@ export function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) function handleStdoutData(chunk: string): void { @@ -108,7 +135,11 @@ export function listFilesWithGit( let start = 0 let idx = buf.indexOf('\0', start) while (idx !== -1) { - processPath(buf.substring(start, idx)) + if (processPath(buf.substring(start, idx))) { + buf = '' + finishAtLimit() + return + } start = idx + 1 idx = buf.indexOf('\0', start) } @@ -131,8 +162,10 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -170,19 +203,27 @@ export function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its git children right away instead of streaming a huge tree the caller // has already abandoned over the shared SSH channel. const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) - return Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn( @@ -191,18 +232,26 @@ export function listFilesWithGit( ) } }) - ]) + const passes = + maxResults === undefined + ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + : runGitLsFiles(primary).then(() => + directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() + ) + + return passes .then(async () => { const files = await expandQuickOpenGitFileListing({ rootPath, gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort() + return files.sort().slice(0, maxResults) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index d76fe40b517..1ca5c2f5327 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -97,6 +97,24 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain('!packages/other/**') }) + it('stops after the primary relay rg pass fills the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' + ) + + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -146,6 +164,35 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain(':(exclude,glob)packages/other/**') }) + it('stops after primary relay Git files fill the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.emit('close', 0, null) + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded relay Git placeholder consume the result budget', async () => { + const primaryProc = createMockProcess() + spawnMock.mockReturnValue(primaryProc) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + }) + it('git fallback fills nested git repos returned as root-relative placeholders', async () => { const root = await makeTempRoot() await writeRel(root, 'README.md') diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index bbe7912ee61..c63a352786e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -29,9 +29,9 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } @@ -67,6 +67,9 @@ export function listFilesWithRg( return true } files.add(relPath) + if (maxResults !== undefined && files.size >= maxResults) { + finishAtLimit() + } return true } @@ -134,6 +137,9 @@ export function listFilesWithRg( if (processLine(passBuf.substring(start, idx))) { passFileCount++ } + if (done) { + return + } start = idx + 1 idx = passBuf.indexOf('\n', start) } @@ -199,6 +205,16 @@ export function listFilesWithRg( } } + function finishAtLimit(): void { + if (done) { + return + } + done = true + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list reached bounded result limit') + resolve(Array.from(files).slice(0, maxResults)) + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its rg children immediately instead of letting them walk the tree to // completion and flood the relay with stdout it will only discard. @@ -212,7 +228,16 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - Promise.all([runPass(primary), runPass(ignoredPass)]) + const passes = + maxResults === undefined + ? Promise.all([runPass(primary), runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) + + passes .then(() => { if (done) { return diff --git a/src/relay/fs-handler-readdir-fallback.test.ts b/src/relay/fs-handler-readdir-fallback.test.ts new file mode 100644 index 00000000000..d3e5591ab32 --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.test.ts @@ -0,0 +1,26 @@ +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 { listFilesWithReaddir } from './fs-handler-readdir-fallback' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('relay readdir file-list fallback', () => { + it('returns a bounded prefix instead of rejecting at the requested result limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-readdir-')) + roots.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'a') + await writeFile(join(root, 'src', 'b.ts'), 'b') + + const files = await listFilesWithReaddir(root, [], { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['src/a.ts', 'src/b.ts']).toContain(files[0]) + }) +}) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index d7c9172183b..25006788f1b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -24,11 +24,12 @@ import { export async function listFilesWithReaddir( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults: options.maxResults, signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index baea79a88c8..9c146af1450 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -356,6 +356,12 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) + const maxResults = + typeof params.maxResults === 'number' && + Number.isInteger(params.maxResults) && + params.maxResults > 0 + ? Math.min(params.maxResults, 20_001) + : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -366,21 +372,22 @@ export class FsHandler { // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes]), + key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]), signal: context?.signal, - start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults) }) } private async runListFilesScan( rootPath: string, excludePathPrefixes: string[], - signal: AbortSignal + signal: AbortSignal, + maxResults?: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -401,7 +408,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -415,7 +422,7 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { // Why: a cancelled scan is not an rg-availability problem; wrapping it // in install-rg guidance would surface bogus advice on the client. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 56626193868..ab19d2e244a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -166,7 +166,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + isRuntimeOwnedSshTargetId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -1003,7 +1007,13 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] + // Why: runtime-owned (ephemeral-VM) targets must never be dialed from + // the renderer — ssh.connect would dispose the runtime layer's live + // relay session. Main's windowless-promotion path can persist such + // ids into this list, so filter at the consumption boundary too. + const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => !isRuntimeOwnedSshTargetId(targetId) + ) if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index 8ddb4a46c9b..f7c20ef51f4 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -1,6 +1,7 @@ import { MessageSquare, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' import { formatAgentTypeLabel } from '@/lib/agent-status' +import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state' import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ @@ -44,28 +45,37 @@ function emptyStateCopy( switch (kind) { case 'loading': return { - title: translate('components.native-chat.state.loading.title', 'Loading conversation…'), + title: translate( + 'components.native-chat.state.loading.title', + NATIVE_CHAT_EMPTY_STATE_COPY.loading.title + ), subtitle: translate( 'components.native-chat.state.loading.subtitle', - 'Reading the agent transcript.' + NATIVE_CHAT_EMPTY_STATE_COPY.loading.subtitle ) } case 'error': return { - title: translate('components.native-chat.state.error.title', 'Could not load conversation'), + title: translate( + 'components.native-chat.state.error.title', + NATIVE_CHAT_EMPTY_STATE_COPY.error.title + ), subtitle: message ?? translate( 'components.native-chat.state.error.subtitle', - 'The transcript could not be read. Toggle back to the terminal to keep working.' + NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle ) } case 'not-agent': return { - title: translate('components.native-chat.state.notAgent.title', 'No conversation here'), + title: translate( + 'components.native-chat.state.notAgent.title', + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.title + ), subtitle: translate( 'components.native-chat.state.notAgent.subtitle', - 'This terminal is not running a recognized coding agent.' + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.subtitle ) } case 'empty': { @@ -73,12 +83,12 @@ function emptyStateCopy( return { title: translate( 'components.native-chat.state.empty.title', - 'Start a chat with {{value0}}', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.title, { value0: agentName } ), subtitle: translate( 'components.native-chat.state.empty.subtitle', - 'Ask {{value0}} to inspect code, explain output, or make a change.', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.subtitle, { value0: agentName } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-diff.ts b/src/renderer/src/components/native-chat/native-chat-diff.ts index 0b0eb791490..fd9bba9a5c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.ts @@ -1,70 +1,6 @@ -// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and -// tool results that contain unified-diff text. The chat renders these as inline -// coloured diffs like the terminal, so detection/parsing is pure and testable. -// Ported from the mobile diffFromToolCall/diffFromText (desktop parity). - -export type DiffLineKind = 'add' | 'del' | 'context' | 'meta' - -export type DiffLine = { - kind: DiffLineKind - text: string -} - -const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) - -function toLines(value: unknown): string[] { - return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : [] -} - -/** Build diff lines from an Edit-style tool call input (old_string/new_string), - * or null when the input isn't an editing payload. Old lines render as deletes, - * new lines as adds — a simple, readable before/after rather than a full LCS. */ -export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null { - if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { - return null - } - const obj = input as Record - const oldText = obj.old_string ?? obj.oldString ?? obj.old - const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text - const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text })) - const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text })) - if (dels.length === 0 && adds.length === 0) { - return null - } - const lines: DiffLine[] = [] - if (typeof obj.file_path === 'string' || typeof obj.path === 'string') { - lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) }) - } - return [...lines, ...dels, ...adds] -} - -/** Parse unified-diff-looking text into coloured lines, or null when the text - * doesn't read as a diff (no +/- lines). */ -export function diffFromText(text: string): DiffLine[] | null { - if (typeof text !== 'string' || text.length === 0) { - return null - } - const raw = text.split('\n') - let added = 0 - let removed = 0 - const lines: DiffLine[] = raw.map((line): DiffLine => { - if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { - return { kind: 'meta', text: line } - } - if (line.startsWith('+') && !line.startsWith('+++')) { - added++ - return { kind: 'add', text: line.slice(1) } - } - if (line.startsWith('-') && !line.startsWith('---')) { - removed++ - return { kind: 'del', text: line.slice(1) } - } - return { kind: 'context', text: line } - }) - // Require a meaningful amount of diff signal so ordinary prose isn't mistaken - // for a diff (a stray leading '-' bullet shouldn't trigger diff rendering). - if (added + removed < 2) { - return null - } - return lines -} +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../../shared/native-chat-diff' diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 8a995a7580d..f8da5702190 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,141 +1,46 @@ -// Pure parser for the live `agentStatus.interactivePrompt` envelope (JSON the -// host captures from the agent's hook). It resolves to either a structured -// question prompt (AskUserQuestion) or a tool-approval (PermissionRequest), the -// two interactive cards the native chat renders just above the composer. Kept -// pure (no React/IO) so the envelope rules are unit-testable. -// -// Why: the Ask question parser (parseQuestionsShape/parseOptions/the -// QUESTION_TOOL_PARSERS registry/parseToolInput/formatAskAnswer) is a byte-for-byte -// mirror of mobile's `mobile-native-chat-ask.ts` — Metro can't import these runtime -// values from src/shared, so both copies must stay in sync; parity is asserted by -// `src/shared/native-chat-ask-parser-parity.test.ts`. The approval-card logic below -// (ChatApproval/parseApprovalFromStatus/ESCAPE) is desktop-only. - -import type { - AskOption, - AskPrompt, - AskQuestion, - InteractiveQuestionParser -} from '../../../../shared/native-chat-ask-types' import { translate } from '@/i18n/i18n' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} from '../../../../shared/native-chat-ask' -export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } +export { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} -/** A detected tool-approval, rendered as an Allow/Deny card. Each option's - * `send` is the literal string written back to the agent's PTY when chosen - * (a number to allow; the ESC char to deny). */ export type ChatApproval = { title: string detail?: string options: { label: string; send: string }[] } -/** The interactive card to render for the current live status, or null. A - * question takes precedence over an approval when both somehow parse. */ export type InteractivePromptCard = | { kind: 'question'; prompt: AskPrompt } | { kind: 'approval'; approval: ChatApproval } | null -// ESC interrupts the agent over the PTY (matches how the composer forwards -// Escape), so "Cancel"/"Deny" sends this byte. const ESCAPE = String.fromCharCode(27) -// Registry of question-tool parsers keyed by the tool name the agent reports. -// To support a new terminal/agent's question tool, register its parser here (or -// via registerQuestionTool) — the renderer and wiring stay unchanged. -const QUESTION_TOOL_PARSERS = new Map() - -export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { - QUESTION_TOOL_PARSERS.set(toolName, parser) -} - -/** Claude's AskUserQuestion shape: `{ questions: [{ question, header, - * multiSelect, options: [{ label, description }] }] }`. Also the de-facto - * default shape, so a new agent that reuses it works without registration. */ -function parseQuestionsShape(input: unknown): AskPrompt | null { - if (!input || typeof input !== 'object') { - return null - } - const rawQuestions = (input as { questions?: unknown }).questions - if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { - return null - } - const questions: AskQuestion[] = [] - for (const raw of rawQuestions) { - if (!raw || typeof raw !== 'object') { - continue - } - const q = raw as Record - const question = typeof q.question === 'string' ? q.question : '' - const options = parseOptions(q.options) - if (question || options.length > 0) { - questions.push({ - question, - header: typeof q.header === 'string' ? q.header : undefined, - multiSelect: q.multiSelect === true, - options - }) - } - } - return questions.length > 0 ? { questions } : null -} - -function parseOptions(raw: unknown): AskOption[] { - if (!Array.isArray(raw)) { - return [] - } - return raw - .map((o): AskOption | null => { - if (typeof o === 'string') { - return { label: o } - } - if (o && typeof o === 'object' && typeof (o as { label?: unknown }).label === 'string') { - const obj = o as { label: string; description?: unknown } - return { - label: obj.label, - description: typeof obj.description === 'string' ? obj.description : undefined - } - } - return null - }) - .filter((o): o is AskOption => o !== null) -} - -// Claude's AskUserQuestion (and aliases) ship the canonical questions shape. -for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { - QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) -} - -/** Resolve an interactive-prompt payload to an AskPrompt: try the tool's - * registered parser first, then fall back to the canonical questions shape so a - * new agent that happens to use the same structure works without registration. */ -function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { - const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined - return (parser ? parser(input) : null) ?? parseQuestionsShape(input) -} - -/** Parse the live `agentStatus.interactivePrompt` (the agent's untruncated - * question-tool input as JSON) into an AskPrompt, or null. Dispatches through - * the tool's registered parser (keyed by `toolName`) with the canonical - * questions shape as the fallback. */ -export function parseAskFromStatus( - interactivePrompt: string | undefined | null, - toolName?: string -): AskPrompt | null { - if (!interactivePrompt) { - return null - } - try { - return parseToolInput(toolName, JSON.parse(interactivePrompt)) - } catch { - return null - } -} - -/** Parse the `{ approval: { tool, summary } }` envelope (emitted by the host on - * a PermissionRequest) into an Allow/Deny card, or null. Allow sends "1"; Deny - * sends ESC — matching the common TUI approval prompt. */ +/** Parse the desktop-only approval envelope; question parsing stays cross-platform. */ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null ): ChatApproval | null { @@ -172,9 +77,6 @@ export function parseApprovalFromStatus( } } -/** Resolve the live `interactivePrompt` to the single card to render. A question - * takes precedence over an approval. `toolName` is forwarded to the Ask branch - * for registry dispatch; the approval branch ignores it. */ export function parseInteractivePrompt( interactivePrompt: string | undefined | null, toolName?: string @@ -184,118 +86,5 @@ export function parseInteractivePrompt( return { kind: 'question', prompt } } const approval = parseApprovalFromStatus(interactivePrompt) - if (approval) { - return { kind: 'approval', approval } - } - return null -} - -/** One question's chosen answer, normalized for delivery: the selected option - * indices (in option order) plus any free-text "other" answer. Index-based (not - * label text) so the answer can be delivered by the selector's stable option - * number — see `buildAskAnswerKeys`. */ -export type AskAnswerSelection = { indices: number[]; other?: string } - -/** A single keystroke group to write to the agent PTY. `raw` bytes (option - * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a - * free-text answer the caller runs through its paste sanitizer before writing. */ -export type AskAnswerKeyGroup = { raw: string } | { text: string } - -/** True when this question is answered (a picked option or typed free text). */ -function isAnswered(sel: AskAnswerSelection | undefined): boolean { - return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 -} - -/** The picked labels + trimmed free text for one question, in option order. */ -function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { - const labels = (sel?.indices ?? []) - .map((i) => question.options[i]?.label ?? '') - .filter((l) => l.length > 0) - const other = (sel?.other ?? '').trim() - return other ? [...labels, other] : labels -} - -/** Build the human-readable answer text: one line per question, in question - * order, each the selected label(s) + free text joined by ", ". Empty answers - * stay empty lines so N lines always == N questions. Used for agents whose - * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ -export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { - return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') -} - -// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits -// the HIGHLIGHTED default (the first option), and pasted label text does not move -// the highlight — so answering by label silently delivered every non-first pick -// as the first option (STA-1860). Instead we drive the selector by each option's -// stable 1-based number (which matches the card's badge), the marker it commits -// on. Right-arrow steps to the next question / the Submit tab. Verified live -// against Claude Code's TUI; groups are written spaced apart (see the desktop -// sender) because a navigation keystroke batched with Enter commits before the -// selector has applied it. -const ASK_ENTER = '\r' -const ASK_NEXT_TAB = '\x1b[C' - -/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. - * Each group is written a step apart so the selector applies it before the next. - * - * - single-select pick → the option number (selects AND commits; in a - * multi-question prompt it auto-advances to the next question) - * - free-text answer → the "Type something" row number, the text, then Enter - * - multi-select → each option number TOGGLES its checkbox, then a step - * to the Submit tab - * - a multi-question prompt (and a lone multi-select) finishes on a Submit - * confirmation, so it ends with one Enter - * - * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always - * address every row.) */ -export function buildAskAnswerKeys( - prompt: AskPrompt, - selections: AskAnswerSelection[] -): AskAnswerKeyGroup[] { - const questions = prompt.questions - const multiQuestion = questions.length > 1 - const groups: AskAnswerKeyGroup[] = [] - - questions.forEach((q, qi) => { - const sel = selections[qi] - const other = (sel?.other ?? '').trim() - const typeSomething = String(q.options.length + 1) - - if (q.multiSelect) { - for (const i of sel?.indices ?? []) { - groups.push({ raw: String(i + 1) }) - } - if (other) { - groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) - } - // A multi-select never auto-advances; step to the next tab (the Submit tab - // when this is the last question). - groups.push({ raw: ASK_NEXT_TAB }) - } else if (other) { - // Single-select can only carry one value, so route any answer that - // includes free text through the "Type something" row as one string. - groups.push( - { raw: typeSomething }, - { text: answerLabels(q, sel).join(', ') }, - { raw: ASK_ENTER } - ) - } else if ((sel?.indices.length ?? 0) > 0) { - groups.push({ raw: String(sel!.indices[0]! + 1) }) - } else if (multiQuestion) { - // Unanswered question in a multi-question prompt: step past it. - groups.push({ raw: ASK_NEXT_TAB }) - } - }) - - const endsOnSubmitTab = - multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) - if (endsOnSubmitTab && groups.length > 0) { - groups.push({ raw: ASK_ENTER }) - } - return groups -} - -/** Whether any question in `selections` carries an answer worth submitting. */ -export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { - return prompt.questions.some((_, i) => isAnswered(selections[i])) + return approval ? { kind: 'approval', approval } : null } diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index bd31627f8ce..7936fd7e231 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -17,9 +17,12 @@ export type NativeChatLiveMergeInput = { agent: AgentType /** Live hook state for the pane, or null when no hook entry exists. */ hookState: AgentStatusState | null - /** True before the initial readSession resolves; forces 'loading'. */ + /** Epoch ms when the current hook state began, or null when unknown. Lets a + * stale 'working' self-heal once this turn's own assistant reply has landed. */ + stateStartedAt?: number | null + /** True before the initial snapshot resolves; forces 'loading'. */ loading?: boolean - /** Set when the initial read failed; forces 'error'. */ + /** Set when the initial snapshot failed; forces 'error'. */ error?: string } @@ -30,13 +33,12 @@ export type NativeChatLiveMergeInput = { * * Precedence: * - error / loading overrides win outright. - * - hook 'working' shows a live working indicator BEFORE the assistant turn - * lands in the transcript. Once the transcript's last message is an - * assistant reply (the turn flushed), 'working' is no longer asserted and - * the derived 'ready' status from the assembler stands. + * - hook 'working' stays authoritative until the hook exits that state OR this + * turn's own assistant reply lands (a trailing reply newer than + * stateStartedAt); a trailing reply from an EARLIER turn does not suppress it. */ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { - const { sources, sessionId, agent, hookState, loading, error } = input + const { sources, sessionId, agent, hookState, stateStartedAt, loading, error } = input if (error) { return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error }) } @@ -44,7 +46,7 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' }) } - const status = liveStatusOverride(sources, hookState) + const status = liveStatusOverride(hookState, sources, stateStartedAt) return assembleNativeChatSession({ sources, sessionId, @@ -54,24 +56,38 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } function liveStatusOverride( + hookState: AgentStatusState | null, sources: NativeChatSources, - hookState: AgentStatusState | null + stateStartedAt: number | null | undefined ): NativeChatSessionStatus | undefined { // Only 'working' drives a live override; blocked/waiting/done leave the // derived (ready/empty) status alone so completed turns render normally. if (hookState !== 'working') { return undefined } - // If the transcript has already flushed the in-flight assistant reply, the - // turn is effectively visible — don't keep asserting 'working' on top of it. - if (lastMessageIsFreshAssistant(sources)) { + // Self-heal a stale 'working' (dropped/late Stop hook): if this turn's own + // assistant reply has already landed, the turn is effectively visible — stop + // asserting 'working'. A trailing reply from a PRIOR turn (older than the + // working turn's start) must not suppress it: the agent is working again. + if (trailingAssistantPostDates(sources, stateStartedAt)) { return undefined } return 'working' } -function lastMessageIsFreshAssistant(sources: NativeChatSources): boolean { - const transcript = sources.transcript ?? [] - const last = transcript.at(-1) - return last?.role === 'assistant' +/** True when the transcript's last message is an assistant reply that landed at + * or after `stateStartedAt`. Unknown timings (no start, no message timestamp) + * return false so the caller keeps 'working' — the safe, non-regressing default. */ +function trailingAssistantPostDates( + sources: NativeChatSources, + stateStartedAt: number | null | undefined +): boolean { + if (stateStartedAt == null) { + return false + } + const last = (sources.transcript ?? []).at(-1) + if (last?.role !== 'assistant' || last.timestamp == null) { + return false + } + return last.timestamp >= stateStartedAt } diff --git a/src/renderer/src/components/native-chat/native-chat-noise.ts b/src/renderer/src/components/native-chat/native-chat-noise.ts index 70e7a1d8cd3..6ef06b130a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -1,36 +1 @@ -import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { isKnownHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' - -// Why: harness machinery turns land in the transcript but are not real user -// messages, so the chat filters them out (they were confusingly rendered as -// the user's own bubbles). The classifier lives in -// src/shared/harness-injected-user-turns.ts, shared with the agent-status -// prompt pipeline; structurally marked turns (isMeta etc.) are already -// dropped by the Claude transcript decoder. - -function messageText(message: NativeChatMessage): string { - return message.blocks - .filter(isTextBlock) - .map((b) => b.text) - .join('') - .trim() -} - -/** True when a message is harness machinery rather than real conversation. Only - * user/system turns qualify — assistant/tool turns and any turn carrying real - * tool activity are always kept. */ -export function isNoiseMessage(message: NativeChatMessage): boolean { - if (message.role !== 'user' && message.role !== 'system') { - return false - } - // Keep turns that carry tool activity (e.g. a user turn with tool results). - if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) { - return false - } - return isKnownHarnessInjectedUserTurnText(messageText(message)) -} - -/** Drop harness-noise messages from a transcript. */ -export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - return messages.filter((m) => !isNoiseMessage(m)) -} +export { isNoiseMessage, stripNoiseMessages } from '../../../../shared/native-chat-noise' diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index fa07de93486..59ff443fd24 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -5,37 +5,21 @@ import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../../shared/native-chat-answer-stepping' import { buildNativeChatImagePasteBytes, buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' -// Why: agent TUIs swallow a `\r` bundled into the same pty write as a framed -// paste, so a one-shot send leaves the text sitting in the input box, unsent. -// Write the body first, then the Enter after a delay so the agent processes the -// paste before the submit. The gap must clear the agent's paste-handling latency -// even while it's BUSY (Codex): a short gap (60ms) fires Enter before a busy -// Codex has landed the paste into its input, so the submit hits an empty box and -// the message sits "Queued" forever. 500ms is orca-runtime's proven value in -// writeTerminalAction({enter:true}), so match it here. -export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 +export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } + export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -// Why: answering Claude's AskUserQuestion is a MULTI-STEP keystroke sequence -// (select an option, advance to the next question, land on Submit). Each step -// re-renders the selector, and a keystroke sent before the next step has -// rendered — or an Enter batched with the navigation before it — lands on the -// wrong (or no) row. This buffer pads the between-keystroke gap so each step -// renders first; kept generous so it still holds on slower machines / under SSH -// round-trip latency, where a tighter cadence misfires the answer. -export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 - -/** Wall-clock gap between successive AskUserQuestion keystroke groups: the proven - * pty submit gap plus the advance buffer that lets each selector step render - * before the next keystroke is written. */ -export const NATIVE_CHAT_QUESTION_STEP_MS = - NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 324cdd48248..849897c386b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -119,22 +119,81 @@ describe('runtime subscribe', () => { } } - it('forwards only appended frames with an array of messages', async () => { + it('normalizes the first frame to a snapshot and reconnect replays to appends', async () => { markRuntimeEnvironmentCompatible(ENV) const { deliver } = stubSubscribe() - const onAppended = vi.fn() + const onFrame = vi.fn() const transport = getNativeChatSessionTransport(ENV) - transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onAppended) + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) await Promise.resolve() deliver({ type: 'appended', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [message('m-snapshot')] }) + deliver({ type: 'replacement', messages: [message('m-replacement')], hasMore: true }) deliver({ type: 'ready' }) // non-appended, ignored deliver({ type: 'appended', messages: undefined }) // no array, ignored deliver({ type: 'appended', messages: [message('m-2')] }, false) // !ok, ignored - expect(onAppended).toHaveBeenCalledTimes(1) - expect(onAppended).toHaveBeenCalledWith([message('m-1')]) + expect(onFrame).toHaveBeenCalledTimes(3) + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [message('m-1')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(2, { + type: 'snapshot', + messages: [message('m-snapshot')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(3, { + type: 'replacement', + messages: [message('m-replacement')], + hasMore: true + }) + }) + + it('settles with an empty snapshot when the first ok frame is an unrecognized shape', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + // An ok response we can't parse must still un-stick the view exactly once, + // carrying any error the runtime sent. + deliver({ type: 'ready', error: 'boom' }) + deliver({ type: 'ready' }) + + expect(onFrame).toHaveBeenCalledTimes(1) + expect(onFrame).toHaveBeenCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'boom' + }) + }) + + it('carries an error on a post-initial reconnect snapshot', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + deliver({ type: 'snapshot', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [], error: 'runtime too old' }) + + expect(onFrame).toHaveBeenLastCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) }) it('sync unsubscribe closes the dedicated stream and issues no unsubscribe RPC (KTD-6)', async () => { @@ -186,6 +245,115 @@ describe('runtime subscribe', () => { } }) + it('keeps retrying when a reconnect subscribe rejects', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + reject: (error: Error) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve, reject) => attempts.push({ callbacks, resolve, reject })) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + attempts[0]!.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() }) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.reject(new Error('reconnect failed')) + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes and retries when an established reconnect returns an error envelope', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + unsubscribe: ReturnType + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + const unsubscribe = vi.fn() + attempts.push({ callbacks, unsubscribe }) + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.callbacks.onResponse({ ok: false }) + attempts[1]!.callbacks.onClose?.() + expect(attempts[1]!.unsubscribe).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes an out-of-order stale handle without replacing the active reconnect', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { onClose?: () => void } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve) => attempts.push({ callbacks, resolve })) + }) + const transport = getNativeChatSessionTransport(ENV) + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + vi.fn() + ) + + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + const activeUnsubscribe = vi.fn() + attempts[1]!.resolve({ unsubscribe: activeUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + const staleUnsubscribe = vi.fn() + attempts[0]!.resolve({ unsubscribe: staleUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + stop() + + expect(staleUnsubscribe).toHaveBeenCalledOnce() + expect(activeUnsubscribe).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + it('closes a stream that resolves after unsubscribe (cancel race)', async () => { markRuntimeEnvironmentCompatible(ENV) const unsubscribe = vi.fn() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 2851a4d84e4..a57d255b6ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -44,7 +44,7 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string { const localNativeChatTransport: NativeChatSessionTransport = { readSession: (agent, sessionId, limit, transcriptPath) => window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), - subscribe: (args, onAppended) => window.api.nativeChat.subscribe(args, onAppended) + subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame) } function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport { @@ -63,25 +63,33 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess return { error: toRuntimeNativeChatErrorMessage(err) } } }, - subscribe: (args, onAppended) => { - const { subscriptionId, agent, sessionId, transcriptPath } = args + subscribe: (args, onFrame) => { + const { subscriptionId, agent, sessionId, transcriptPath, limit } = args let cancelled = false + let receivedInitial = false let handleUnsubscribe: (() => void) | null = null let reconnectTimer: ReturnType | null = null + let activeAttempt = 0 + let reconnectPendingAttempt: number | null = null // A mid-stream drop (runtime restart, network blip, relay reconnect) closes // the dedicated stream socket. Re-open it after a short backoff so the live // tail resumes without a manual chat toggle; the fresh drain re-emits the // windowed tail, which merges by id so no turn is duplicated. An initial // connect failure lands in `.catch` instead (and is surfaced through the - // parallel readSession error), so a too-old/absent runtime never spins here. - const scheduleReconnect = (): void => { + // initial error frame), so a too-old/absent runtime never spins here. + const scheduleReconnect = (attempt: number): void => { + if (attempt !== activeAttempt) { + return + } handleUnsubscribe = null + reconnectPendingAttempt = attempt if (cancelled || reconnectTimer) { return } reconnectTimer = setTimeout(() => { reconnectTimer = null + reconnectPendingAttempt = null if (!cancelled) { openStream() } @@ -89,48 +97,121 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess } const openStream = (): void => { + const attempt = ++activeAttempt void window.api.runtimeEnvironments .subscribe( { selector: environmentId, method: 'nativeChat.subscribe', - params: { subscriptionId, agent, sessionId, transcriptPath }, + params: { subscriptionId, agent, sessionId, transcriptPath, limit }, timeoutMs: 15_000 }, { onResponse: (response) => { - if (cancelled) { + if (cancelled || attempt !== activeAttempt) { return } if (response.ok === false) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(new RuntimeRpcCallError(response)) + }) + } else { + handleUnsubscribe?.() + scheduleReconnect(attempt) + } return } const frame = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (frame?.type === 'appended' && Array.isArray(frame.messages)) { - onAppended(frame.messages) + if ( + (frame?.type === 'appended' || + frame?.type === 'snapshot' || + frame?.type === 'replacement') && + Array.isArray(frame.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), + ...(frame.error ? { error: frame.error } : {}) + }) + } else if (frame.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? false, + ...(frame.error ? { error: frame.error } : {}) + }) + } else { + onFrame( + frame.type === 'replacement' + ? { + type: 'replacement', + messages: frame.messages, + hasMore: frame.hasMore ?? false + } + : { type: 'appended', messages: frame.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize + // would otherwise never flip receivedInitial, stranding the view + // on 'loading'. Settle it with an empty snapshot (carrying any + // error the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(frame?.error ? { error: frame.error } : {}) + }) } }, // Established-then-dropped: resume the tail. onClose also fires on our // own teardown, but `cancelled` short-circuits the reconnect there. - onError: scheduleReconnect, - onClose: scheduleReconnect + onError: () => scheduleReconnect(attempt), + onClose: () => scheduleReconnect(attempt) } ) .then((handle) => { - handleUnsubscribe = handle.unsubscribe - // The stream resolved after teardown already ran — close it now so the - // late-arriving handle doesn't leak (same race as runtime-file-client). - if (cancelled) { + // Why: reconnect attempts may resolve out of order. A stale handle + // must never replace or tear down the current stream. + if (cancelled || attempt !== activeAttempt || reconnectPendingAttempt === attempt) { handle.unsubscribe() - handleUnsubscribe = null + return } + handleUnsubscribe = handle.unsubscribe }) - .catch(() => { + .catch((err: unknown) => { + if (cancelled || attempt !== activeAttempt) { + return + } // Initial subscribe failed (e.g. a too-old runtime lacking the method). - // The parallel readSession surfaces the error; don't reconnect-spin. + // Surface the same compatibility-specific copy as a direct read. + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(err) + }) + return + } + // Why: a failed reconnect is still a transient dropped-stream state; + // keep retrying after the backoff instead of stranding a live view. + scheduleReconnect(attempt) }) } @@ -147,6 +228,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess clearTimeout(reconnectTimer) reconnectTimer = null } + reconnectPendingAttempt = null handleUnsubscribe?.() handleUnsubscribe = null } diff --git a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts index f49370f415b..e18bf8f4764 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts @@ -1,58 +1,6 @@ -// Pure folding logic for the native chat tool runs. Claude emits each tool call -// as its own assistant message and each result as a tool-role message; folding -// every tool-only message into the preceding assistant turn lets the view -// collapse a whole turn's tool activity under one "N tool calls" line. Kept out -// of the .tsx so the merge/split rules are unit-testable without rendering. -// Ported from the mobile foldToolMessages/splitNativeChatBlocks (mobile parity). - -import { - isToolCallBlock, - isToolResultBlock, - type NativeChatBlock, - type NativeChatMessage -} from '../../../../shared/native-chat-types' - -/** True when a message carries nothing but tool calls/results — the shape Claude - * emits for the tool half of a turn. */ -function isToolOnlyMessage(message: NativeChatMessage): boolean { - return ( - message.blocks.length > 0 && - message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) - ) -} - -/** Fold a turn's tool activity into the assistant message it belongs to, so the - * view can collapse a whole turn's tools under one line. A tool-only message is - * merged into the preceding assistant turn when one exists; otherwise it stands - * on its own (e.g. an orphan result with no preceding assistant prose). */ -export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - const out: NativeChatMessage[] = [] - for (const message of messages) { - const prev = out.at(-1) - if (isToolOnlyMessage(message) && prev && prev.role === 'assistant') { - out[out.length - 1] = { ...prev, blocks: [...prev.blocks, ...message.blocks] } - } else { - out.push(message) - } - } - return out -} - -/** Split a message's blocks into prose (text/image) and tool (call/result), so - * the view renders the agent's words first and folds the tool activity into a - * separate collapsible run beneath it. */ -export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { - prose: NativeChatBlock[] - tools: NativeChatBlock[] -} { - const prose: NativeChatBlock[] = [] - const tools: NativeChatBlock[] = [] - for (const block of blocks) { - if (isToolCallBlock(block) || isToolResultBlock(block)) { - tools.push(block) - } else { - prose.push(block) - } - } - return { prose, tools } -} +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair +} from '../../../../shared/native-chat-tool-fold' diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts index d79ab1f43c9..2122c1fb7da 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts @@ -1,101 +1,8 @@ -// Why: tool-call inputs are arbitrary JSON; the chat row only shows a one-line -// preview so a long file body or diff doesn't dominate the conversation. Kept -// pure so the truncation/serialization rules are unit-testable. Ported from the -// mobile summarizeToolInput/summarizeToolRun (desktop parity). - -import { basename } from '../../lib/path' -import { isToolCallBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' - -const MAX_PREVIEW_LENGTH = 80 - -/** One-line, length-capped preview of a tool-call input payload. Strings pass - * through; objects/arrays serialize to compact JSON; everything collapses - * whitespace and truncates with an ellipsis. Returns '' when there's nothing - * worth showing. */ -export function summarizeToolInput(input: unknown): string { - const raw = toRawPreview(input) - const collapsed = raw.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { - return collapsed - } - return `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` -} - -/** Full, pretty-printed tool-call input for the expanded detail view. Strings - * pass through as-is; objects/arrays print as indented JSON so a diff-less call - * (e.g. a question payload) reads cleanly instead of one long minified line. */ -export function formatToolInput(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input, null, 2) ?? '' - } catch { - return '' - } -} - -/** A very short hint for a tool call in a one-line run summary: the target file's - * basename when present, else a clipped preview of the input. */ -export function briefToolArg(input: unknown): string { - if (input && typeof input === 'object') { - const obj = input as Record - const path = obj.file_path ?? obj.path ?? obj.notebook_path - if (typeof path === 'string' && path.length > 0) { - return basename(path) - } - const cmd = obj.command ?? obj.cmd ?? obj.query ?? obj.pattern - if (typeof cmd === 'string') { - return summarizeToolInput(cmd).slice(0, 28) - } - } - return summarizeToolInput(input).slice(0, 28) -} - -/** One-line summary of a run of tool calls: "Bash git status · Edit app.tsx · …". - * Only tool-call blocks contribute names; results are skipped. */ -export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (!isToolCallBlock(block)) { - continue - } - const name = block.name.trim() - // Skip nameless tool calls so the join can't produce orphan separators - // ("Tool · ·"). - if (!name) { - continue - } - const brief = briefToolArg(block.input) - parts.push(brief ? `${name} ${brief}` : name) - } - return parts.join(' · ') -} - -/** Count the tool calls in a run (used for the "N tool calls" fold label). */ -export function countToolCalls(blocks: readonly NativeChatBlock[]): number { - return blocks.filter(isToolCallBlock).length -} - -function toRawPreview(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input) ?? '' - } catch { - return '' - } -} +export { + briefToolArg, + countToolCalls, + formatToolInput, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../../shared/native-chat-tool-summary' diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index e7c70e120cf..cb49cd9c57d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' +import { shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' import { buildAskAnswerKeys, formatAskAnswer, @@ -80,10 +81,11 @@ export function useNativeChatInteractiveSend( // highlighted option, not a pasted label, so answer it with per-option // keystrokes (by option number), paced so each step renders before the next. // Other agents' question tools commit a pasted answer, so send label text. - const handle: NativeChatSendHandle = - agent === 'claude' - ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) - : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) + // Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which + // runs the same selector — takes the keystroke path too. + const handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent) + ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) + : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) inFlightRef.current = handle return handle.settleAfterMs }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index f60ce34d3c8..89ae267c8e5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -15,16 +15,31 @@ const { transportFactory, getMockTransport, resetMockTransports } = vi.hoisted(( readSession: ReturnType subscribe: ReturnType unsubscribe: ReturnType + emit: (frame: unknown) => void } const transports = new Map() - const getMockTransport = (ownerId: string | null): MockTransport => { + // autoSnapshot mirrors a watchable transcript's immediate first frame; pass + // false to model a not-yet-flushed transcript whose server-side resolve poll + // has nothing to emit yet (#8401) — only readSession drives the view then. + const getMockTransport = ( + ownerId: string | null, + opts?: { autoSnapshot?: boolean } + ): MockTransport => { let transport = transports.get(ownerId) if (!transport) { const unsubscribe = vi.fn() + let listener: (frame: unknown) => void = () => {} transport = { unsubscribe, readSession: vi.fn().mockResolvedValue({ messages: [] }), - subscribe: vi.fn(() => unsubscribe) + subscribe: vi.fn((_args, onFrame) => { + listener = onFrame + if (opts?.autoSnapshot !== false) { + onFrame({ type: 'snapshot', messages: [], hasMore: false }) + } + return unsubscribe + }), + emit: (frame) => listener(frame) } transports.set(ownerId, transport) } @@ -74,16 +89,42 @@ describe('mergeNativeChatLiveSession', () => { expect(session.messages).toHaveLength(1) }) - it("clears 'working' once the assistant message flushes to the transcript", () => { + it("keeps 'working' authoritative when a prior assistant message is present", () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, sessionId: 'sess', agent: 'claude', hookState: 'working' }) + expect(session.status).toBe('working') + }) + + it("self-heals a stale 'working' once this turn's assistant reply lands", () => { + // Trailing assistant reply (ts 2) post-dates the working turn's start (ts 1), + // so a dropped/late Stop hook must not strand 'working' on a visible reply. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1 + }) expect(session.status).toBe('ready') }) + it("keeps 'working' when the trailing assistant reply predates the working turn", () => { + // Trailing reply (ts 2) is older than the new working turn (started at ts 5), + // so the agent is working again — the stale reply must not suppress 'working'. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5 + }) + expect(session.status).toBe('working') + }) + it('leaves completed states (done/waiting/blocked) on the derived status', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'hi')] }, @@ -177,17 +218,14 @@ describe('useNativeChatLiveSession — transport routing', () => { resetMockTransports() }) - it('routes the initial read and subscribe through the runtime transport', async () => { + it('seeds from readSession alongside the subscription for the initial runtime load', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(transportFactory).toHaveBeenCalledWith('env-1') const transport = getMockTransport('env-1') - expect(transport.readSession).toHaveBeenCalledWith( - AGENT, - SESSION, - expect.any(Number), - undefined - ) + // The independent seed guards against a stuck 'loading' when the stream never + // delivers a snapshot; a live snapshot still wins when it does (see below). + expect(transport.readSession).toHaveBeenCalledOnce() expect(transport.subscribe).toHaveBeenCalledOnce() }) @@ -228,11 +266,16 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(transport.unsubscribe).toHaveBeenCalledOnce() }) - it('surfaces a runtime read error in the error phase (R4 end-to-end)', async () => { - getMockTransport('env-1').readSession.mockResolvedValueOnce({ error: 'runtime too old' }) - + it('surfaces a runtime snapshot error in the error phase (R4 end-to-end)', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) - await flush() + await act(async () => { + getMockTransport('env-1').emit({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) + }) expect(latest?.status).toBe('error') expect(latest?.error).toBe('runtime too old') @@ -261,11 +304,6 @@ describe('useNativeChatLiveSession — transport routing', () => { assistant(`m-${n}`, 't') ) const first = getMockTransport('env-1') - first.readSession.mockResolvedValueOnce({ messages: many }) - let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} - first.readSession.mockImplementationOnce( - () => new Promise((resolve) => (resolveEarlier = resolve)) - ) const root = await render({ paneKey: PANE, @@ -273,6 +311,14 @@ describe('useNativeChatLiveSession — transport routing', () => { sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => { + first.emit({ type: 'snapshot', messages: many, hasMore: true }) + }) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + first.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) // Kick off load-earlier against env-1, then flip the owner before it resolves. await act(async () => { latest?.loadEarlier() @@ -292,6 +338,162 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) + + it('discards a load-earlier resolve from before transcript replacement', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'replacement', + messages: [assistant('replacement', 'new inode')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old inode')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['replacement']) + }) + + it('discards a load-earlier resolve from before a reconnect snapshot', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [assistant('reconnected', 'fresh snapshot')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old generation')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['reconnected']) + }) + + it('discards a load-earlier resolve from before a transcript-path rebind', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-path-${n}`, 'old') + ) + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/old/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/new/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-old-path', 'old transcript')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).not.toContain('stale-old-path') + }) + + it('seeds ready from readSession when the subscription never delivers a frame', async () => { + const transport = getMockTransport('env-1') + // Older runtime: the stream wires only appends and stays silent for an empty + // tail, so no snapshot arrives — the independent seed must settle the view. + transport.subscribe.mockImplementation(() => transport.unsubscribe) + transport.readSession.mockResolvedValueOnce({ messages: [user('u-seed', 'hi')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((message) => message.id)).toEqual(['u-seed']) + }) + + it('keeps the live snapshot when a readSession seed resolves after it', async () => { + const transport = getMockTransport('env-1') + // The seed resolves only after the authoritative snapshot has already landed. + let resolveSeed: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveSeed = resolve)) + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ type: 'snapshot', messages: [user('u-live', 'live')], hasMore: false }) + ) + await act(async () => { + resolveSeed({ messages: [user('u-stale', 'stale')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['u-live']) + }) + + it("self-heals a stale 'working' hook once this turn's reply has landed", async () => { + // The hook must read stateStartedAt from the store and thread it into the + // merge, so a dropped Stop hook doesn't strand 'Agent is working'. + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'go'), assistant('a-1', 'done')], + hasMore: false + }) + ) + + // The reply (ts 2) post-dates stateStartedAt (1): the stuck 'working' heals. + expect(latest?.status).toBe('ready') + }) }) // Regression for #8401: a just-created Claude Code session's transcript can @@ -338,7 +540,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) @@ -358,7 +560,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('surfaces an error once the ~60s retry window is exhausted', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -374,22 +576,15 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('renders live-appended content instead of loading while the read is still retrying', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('loading') // The watcher's first drain lands mid-retry — content must win over the spinner. await act(async () => { - onAppended?.([assistant('a-early', 'landed during retry')]) + transport.emit({ type: 'appended', messages: [assistant('a-early', 'landed during retry')] }) }) expect(latest?.status).not.toBe('loading') @@ -397,21 +592,14 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { }) it('renders live-appended content even when the initial read settled into a permanent error', async () => { - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('error') await act(async () => { - onAppended?.([assistant('a-late', 'landed late')]) + transport.emit({ type: 'appended', messages: [assistant('a-late', 'landed late')] }) }) expect(latest?.status).not.toBe('error') diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 384ae724769..ec1ebbc8226 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -8,7 +8,6 @@ import { } from '../../../../shared/native-chat-types' import { applyAppend, - boundNativeChatWindow, createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' @@ -130,7 +129,7 @@ export function useNativeChatLiveSession( // The active read window; raised by loadEarlier to page in older history. const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT) - // Appended messages accumulate separately from the initial read so a re-read + // Appended messages accumulate separately from the initial snapshot so pagination // (session change or load-earlier) doesn't lose in-flight appends mid-swap; // they reset with the same effect that re-subscribes. Live frames merge by id // (re-emitted ids replace in place, no unbounded concat) and the bucket is @@ -143,6 +142,11 @@ export function useNativeChatLiveSession( // Live hook state for this pane, selected narrowly so unrelated status churn // doesn't re-render the chat view. const hookState = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state ?? null) + // When that state began (epoch ms). A separate primitive selector so it doesn't + // churn renders; lets a stale 'working' self-heal once this turn's reply lands. + const hookStateStartedAt = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) const latestSessionId = useRef(sessionId) latestSessionId.current = sessionId @@ -150,6 +154,7 @@ export function useNativeChatLiveSession( // host is discarded after an owner flip (the session id can stay the same). const latestTransport = useRef(transport) latestTransport.current = transport + const transcriptEpochRef = useRef(0) // Incremental assembler: reset on the base axis (session/agent/read swap), // applyAppends on the hot append axis. `appliedTranscriptRef` is the exact @@ -161,6 +166,10 @@ export function useNativeChatLiveSession( const baseMessagesRef = useRef(EMPTY_MESSAGES) useEffect(() => { + // Why: agent/path/owner rebinds can keep the same session and transport; + // every source generation must invalidate pagination captured before it. + transcriptEpochRef.current += 1 + setLoadingEarlier(false) if (!sessionId) { // No session id yet: nothing to read or tail. Surface live hook state on // an empty transcript; backfills once the id arrives (effect re-runs). @@ -172,6 +181,9 @@ export function useNativeChatLiveSession( } let cancelled = false + // Set by the first authoritative snapshot/replacement frame so the + // independent readSession seed below can never clobber a live snapshot. + let frameArrived = false let retryTimer: ReturnType | null = null const retryStartedAt = Date.now() // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing @@ -184,11 +196,19 @@ export function useNativeChatLiveSession( setAppended([]) setHasMore(false) + // Independent initial seed: the subscribe stream normally delivers the first + // snapshot, but a persistent initial-drain error, or an older runtime whose + // subscribe only wires onAppend, would otherwise strand the view at 'loading' + // forever. Apply this only while no authoritative frame has landed yet, so a + // live snapshot always wins and a late seed never repaints it. function loadSession(attempt: number): void { + if (frameArrived) { + return + } void transport .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) .then((result) => { - if (cancelled) { + if (cancelled || frameArrived) { return } if (result && 'error' in result) { @@ -209,7 +229,7 @@ export function useNativeChatLiveSession( setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) }) .catch((err: unknown) => { - if (!cancelled) { + if (!cancelled && !frameArrived) { setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) } }) @@ -219,19 +239,36 @@ export function useNativeChatLiveSession( const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( - { subscriptionId, agent, sessionId, transcriptPath: transcriptPath ?? undefined }, - (messages) => { + { + subscriptionId, + agent, + sessionId, + transcriptPath: transcriptPath ?? undefined, + limit: limitRef.current + }, + (frame) => { if (!cancelled) { + if (frame.type === 'snapshot' || frame.type === 'replacement') { + // Why: reconnect snapshots and inode replacements are both + // authoritative generations; older pagination must not repaint them. + frameArrived = true + transcriptEpochRef.current += 1 + setLoadingEarlier(false) + if ('error' in frame && frame.error) { + setRead({ phase: 'error', error: frame.error }) + return + } + replaceList(appendMergerRef.current, frame.messages) + setAppended([]) + setRead({ phase: 'ready', messages: appendMergerRef.current.list }) + setHasMore(frame.hasMore) + return + } // Merge by id (re-emits replace in place) then bound to the window so // the bucket can't grow without limit. The base read still holds older // turns, and the assembler re-dedups the concat, so trimming the recent // append tail can't drop a turn the base window still covers (#6). - const merged = applyAppend(appendMergerRef.current, messages) - const bounded = boundNativeChatWindow(merged, limitRef.current) - if (bounded !== merged) { - replaceList(appendMergerRef.current, bounded) - } - setAppended(appendMergerRef.current.list) + setAppended(applyAppend(appendMergerRef.current, frame.messages, limitRef.current)) } } ) @@ -266,13 +303,18 @@ export function useNativeChatLiveSession( return } const nextLimit = nextNativeChatLimit(limitRef.current) + const requestEpoch = transcriptEpochRef.current setLoadingEarlier(true) void transport .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) .then((result) => { // Ignore a stale resolve from a session that swapped OR an owner that // flipped underneath us — either would paint the wrong host's history. - if (latestSessionId.current !== sessionId || latestTransport.current !== transport) { + if ( + latestSessionId.current !== sessionId || + latestTransport.current !== transport || + transcriptEpochRef.current !== requestEpoch + ) { return } if (!result || 'error' in result) { @@ -293,7 +335,9 @@ export function useNativeChatLiveSession( // Always clear the loading flag — even after a session swap — so a stale // resolve can't leave loadingEarlier stuck true on the new session. Only // APPLYING the result above is gated on the session-id match. - setLoadingEarlier(false) + if (transcriptEpochRef.current === requestEpoch) { + setLoadingEarlier(false) + } }) }, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase]) @@ -307,7 +351,7 @@ export function useNativeChatLiveSession( // Base axis: the read's message array reference changes on session swap and // loadEarlier; sessionId/agent identify the conversation. Any change forces a // full reset so a missed trigger can't leave the cache stale. - const baseSig = `${agent}${sessionId ?? ''}` + const baseSig = `${agent}\u0000${sessionId ?? ''}` const baseChanged = baseSig !== baseSigRef.current || baseMessages !== baseMessagesRef.current const applied = appliedTranscriptRef.current const isSuffixExtension = @@ -338,6 +382,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + stateStartedAt: hookStateStartedAt, // Why: a watcher append (fix for #8401) can land content while the read is // still retrying ('loading') or after it settled into 'error' — in both // cases showing the live content beats a spinner or a stale error, so each @@ -352,6 +397,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + hookStateStartedAt, hasMore, loadingEarlier, loadEarlier, diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 31afc7d6843..9d5bb1cbbab 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -17,6 +17,7 @@ import { import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { getLanguageEntries, + getMenuBarIconEntries, getSystemTrayEntries, getThemeEntries, getTitlebarEntries, @@ -37,6 +38,7 @@ type AppearanceInterfaceSectionProps = { updateSettings: (updates: Partial) => void applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] + isDesktopMac: boolean isDesktopWindows: boolean onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean @@ -47,6 +49,7 @@ export function AppearanceInterfaceSection({ updateSettings, applyTheme, fontSuggestions, + isDesktopMac, isDesktopWindows, onRequestFontSuggestions, forceVisiblePrimary = false @@ -56,6 +59,7 @@ export function AppearanceInterfaceSection({ const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in') const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out') const languageEntry = getLanguageEntries()[0] + const menuBarIconEntry = getMenuBarIconEntries({ showMenuBarIcon: true })[0] const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0] const themeEntry = getThemeEntries()[0] const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme') @@ -65,7 +69,8 @@ export function AppearanceInterfaceSection({ const advancedEntries = [ ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries) @@ -236,6 +241,28 @@ export function AppearanceInterfaceSection({ /> ) : null} + + {isDesktopMac ? ( + + + updateSettings({ showMenuBarIcon: settings.showMenuBarIcon === false }) + } + /> + + ) : null} ) : null} diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index ca68ff7e47c..0550e9704a6 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -10,6 +10,7 @@ import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { + appPlatform: 'linux' as NodeJS.Platform, availableStatusBarToggles: [] as { description: string id: StatusBarItem @@ -179,12 +180,16 @@ describe('AppearancePane', () => { beforeEach(() => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] + mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. ;(window as unknown as { api: unknown }).api = { + platform: { + get: () => ({ platform: mocks.state.appPlatform }) + }, ui: { getZoomLevel: () => 0, onTerminalZoom: () => () => {}, @@ -359,6 +364,23 @@ describe('AppearancePane', () => { ).not.toBeNull() }) + it('shows and updates the menu bar icon preference only on desktop macOS', async () => { + mocks.state.appPlatform = 'darwin' + mocks.state.settingsSearchQuery = 'menu bar' + const updateSettings = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), updateSettings) + const toggle = container.querySelector( + 'button[role="switch"][aria-label="Show Menu Bar Icon"]' + ) + + expect(toggle).not.toBeNull() + expect(container.textContent).not.toContain('Minimize to Tray on Close') + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(updateSettings).toHaveBeenCalledWith({ showMenuBarIcon: false }) + }) + it('keeps description-only search matches visible after helper text is hidden', async () => { mocks.state.settingsSearchQuery = 'app window' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 631059d6d3a..58954d32565 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -16,6 +16,7 @@ import { getAppearancePaneSearchEntries, getLanguageEntries, getLayoutEntries, + getMenuBarIconEntries, getSidebarEntries, getStatusBarEntries, getSystemTrayEntries, @@ -85,6 +86,7 @@ export function AppearancePane({ // Why: the system tray behavior is desktop-Electron Windows-only; a Windows // browser web client has no local tray to control. const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient + const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient const [manuallyOpenSection, setManuallyOpenSection] = useState( 'interface' @@ -134,7 +136,8 @@ export function AppearancePane({ ...getTypographyEntries(), ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const terminalSearchEntries = [ { title: terminalTitle }, @@ -207,6 +210,7 @@ export function AppearancePane({ applyTheme={applyTheme} fontSuggestions={fontSuggestions} onRequestFontSuggestions={onRequestFontSuggestions} + isDesktopMac={isDesktopMac} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 3ceaf412d13..3e15d266221 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -2,15 +2,19 @@ import type { SettingsSearchEntry } from './settings-search' import { getTerminalAppearanceSearchEntries } from './terminal-search' import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { getRendererAppPlatform } from '@/lib/renderer-app-platform' -import { isWebClientLocation } from '@/lib/web-client-location' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' +import { getMenuBarIconEntries, getSystemTrayEntries } from './appearance-system-presence-search' -export { getStatusBarToggles, getUsagePercentageDisplayEntry } +export { + getMenuBarIconEntries, + getStatusBarToggles, + getSystemTrayEntries, + getUsagePercentageDisplayEntry +} export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -188,65 +192,6 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[ } ]) -const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ - { - title: translate( - 'auto.components.settings.appearance.search.9a115966d3', - 'Minimize to Tray on Close' - ), - description: translate( - 'auto.components.settings.appearance.search.4d5b9427b5', - 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { - englishOnly: true - }), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.system', - 'system tray', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.minimize', - 'minimize', - { englishOnly: true } - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { - englishOnly: true - }), - ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.notification', - 'notification area', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.background', - 'background', - { englishOnly: true } - ) - ] - } -]) - -type SystemTraySearchOptions = { - showSystemTray?: boolean -} - -function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean { - return ( - options.showSystemTray ?? - // Why: this setting controls Electron's Windows tray only. A Windows web - // browser can report win32, but it has no local tray to affect. - (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) - ) -} - -export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] { - return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : [] -} - const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface') @@ -269,6 +214,7 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt type AppearancePaneSearchOptions = { showWarpImport?: boolean showSystemTray?: boolean + showMenuBarIcon?: boolean } function buildAppearancePaneSearchEntries( @@ -286,7 +232,8 @@ function buildAppearancePaneSearchEntries( ...getStatusBarEntries(), ...getSidebarEntries(), ...getAppIconEntries(), - ...getSystemTrayEntries(options) + ...getSystemTrayEntries(options), + ...getMenuBarIconEntries(options) ] } @@ -295,6 +242,7 @@ export function getAppearancePaneSearchEntries( ): SettingsSearchEntry[] { return buildAppearancePaneSearchEntries({ showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray + showSystemTray: options.showSystemTray, + showMenuBarIcon: options.showMenuBarIcon }) } diff --git a/src/renderer/src/components/settings/appearance-system-presence-search.ts b/src/renderer/src/components/settings/appearance-system-presence-search.ts new file mode 100644 index 00000000000..f392da6c271 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-system-presence-search.ts @@ -0,0 +1,103 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { SettingsSearchEntry } from './settings-search' +import { translateSearchKeyword } from './settings-search-keywords' + +const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate( + 'auto.components.settings.appearance.search.9a115966d3', + 'Minimize to Tray on Close' + ), + description: translate( + 'auto.components.settings.appearance.search.4d5b9427b5', + 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.system', + 'system tray', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.minimize', + 'minimize', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.notification', + 'notification area', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +const getMenuBarIconEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon'), + description: translate( + 'settings.appearance.menuBarIcon.description', + 'Keep an Orca shortcut and activity indicator in the macOS menu bar.' + ), + keywords: [ + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.menuBar', 'menu bar', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.1f2880a9d5', 'orca'), + ...translateSearchKeyword( + 'settings.appearance.menuBarIcon.keyword.statusItem', + 'status item', + { englishOnly: true } + ), + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.activity', 'activity', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +type SystemPresenceSearchOptions = { + showMenuBarIcon?: boolean + showSystemTray?: boolean +} + +export function getSystemTrayEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showSystemTray ?? + // Why: a Windows web client can report win32, but it has no local tray. + (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) + return show ? getSystemTrayEntryCatalog() : [] +} + +export function getMenuBarIconEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showMenuBarIcon ?? (getRendererAppPlatform() === 'darwin' && !isWebClientLocation()) + return show ? getMenuBarIconEntryCatalog() : [] +} diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 5811f5dcf02..bf642e83735 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -172,6 +172,15 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch('tray', webEntries)).toBe(false) }) + it('includes the macOS menu bar entry only when its desktop control is shown', () => { + const macEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: true }) + const otherEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: false }) + + expect(macEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(true) + expect(otherEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(false) + expect(matchesSettingsSearch('status item', macEntries)).toBe(true) + }) + it('keeps sidebar shortcut restore settings in the Appearance search index', () => { const automationsEntry = getSidebarEntries().find( (entry) => entry.title === 'Show Automations Button' diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 86791576c1c..16f5deaddd8 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -313,6 +313,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -455,6 +456,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -666,6 +668,7 @@ describe('useIpcEvents rate-limit hydration', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), getZoomLevel: vi.fn(() => 0) }) } @@ -954,6 +957,7 @@ describe('useIpcEvents browser tab create routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1176,6 +1180,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1295,6 +1300,130 @@ describe('useIpcEvents updater integration', () => { expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1') }) + it('opens Settings from a Settings intent queued before the listener attached', async () => { + const openSettingsPage = vi.fn() + let onOpenSettingsRegistered = false + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + openSettingsPage, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + setUpdateStatus: vi.fn(), + setRateLimitsFromPush: vi.fn(), + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + + const makeEvents = (target: Record = {}): Record => + new Proxy(target, { + get: (namespace, prop) => + prop in namespace ? Reflect.get(namespace, prop) : () => () => {} + }) + + vi.stubGlobal('window', { + api: { + repos: makeEvents(), + worktrees: makeEvents(), + keybindings: makeEvents(), + settings: makeEvents(), + browser: makeEvents(), + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {}, + onBrowserDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + agentStatus: { onSet: () => () => {} }, + ui: makeEvents({ + onOpenSettings: () => { + onOpenSettingsRegistered = true + return () => {} + }, + // Why: exercise the positive branch — an intent queued before mount is + // pulled once the renderer's onOpenSettings listener is attached. + consumePendingOpenSettings: () => Promise.resolve(true), + getZoomLevel: () => 0, + set: vi.fn() + }) + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + // Why: the pending-intent pull is a Promise; flush microtasks before asserting. + await Promise.resolve() + await Promise.resolve() + + expect(onOpenSettingsRegistered).toBe(true) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + }) + it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { const clearTabPtyId = vi.fn() const setSshConnectionState = vi.fn() @@ -1421,6 +1550,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1862,6 +1992,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -2809,6 +2940,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3365,6 +3497,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3583,6 +3716,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3796,6 +3930,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4027,6 +4162,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4284,6 +4420,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4518,6 +4655,7 @@ describe('useIpcEvents agent status snapshot integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e84df727e4d..838cf50720e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1215,6 +1215,18 @@ export function useIpcEvents(): void { }) ) + // Why: a tray/menu-bar "Settings…" click can fire before this listener + // attaches on a fresh window; consume any intent queued for us. Guarded + // with `?.` so a stale preload bundle doesn't crash the listener set. + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + unsubs.push( window.api.ui.onOpenSetupGuide?.(() => { useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index e39c75db2bf..60ab54ac4c4 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -382,7 +382,8 @@ export function buildSettingsNavigationMetadata({ icon: Palette, searchEntries: getAppearancePaneSearchEntries({ showWarpImport: showDesktopOnlySettings, - showSystemTray: showDesktopOnlySettings && isWindows + showSystemTray: showDesktopOnlySettings && isWindows, + showMenuBarIcon: showDesktopOnlySettings && isMac }), group: 'interface' }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9f9f17aacf2..16ac39c5356 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." + }, + "menuBarIcon": { + "title": "Show Menu Bar Icon", + "description": "Keep an Orca shortcut and activity indicator in the macOS menu bar." } } }, @@ -67,7 +71,8 @@ "quit": "Quit", "minimizeNotice": { "body": "Orca is still running in the system tray" - } + }, + "activityWaiting": "Orca - activity waiting" }, "worktreeJumpPalette": { "matchLabel": { @@ -13196,6 +13201,7 @@ "title": "Could not load conversation", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." }, + "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", "subtitle": "This terminal is not running a recognized coding agent." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 291f070df87..7550f44db52 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." + }, + "menuBarIcon": { + "title": "Mostrar icono en la barra de menús", + "description": "Mantén un acceso directo de Orca e indicador de actividad en la barra de menús de macOS." } } }, @@ -67,7 +71,8 @@ "quit": "Salir", "minimizeNotice": { "body": "Orca sigue ejecutándose en la bandeja del sistema" - } + }, + "activityWaiting": "Orca: actividad en espera" }, "worktreeJumpPalette": { "matchLabel": { @@ -13196,6 +13201,7 @@ "title": "No se pudo cargar la conversación", "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." }, + "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", "subtitle": "Este terminal no está ejecutando un agente de código reconocido." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1694638adb6..8a45197a9a0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -31,6 +31,10 @@ "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" + }, + "menuBarIcon": { + "title": "メニューバーアイコンを表示", + "description": "macOS メニューバーに Orca のショートカットとアクティビティインジケーターを表示します。" } } }, @@ -67,7 +71,8 @@ "quit": "終了", "minimizeNotice": { "body": "Orcaはシステムトレイで実行中です" - } + }, + "activityWaiting": "Orca - アクティビティ待機中" }, "worktreeJumpPalette": { "matchLabel": { @@ -13196,6 +13201,7 @@ "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、terminal に切り替えてください。" }, + "pairHost": "ホストをペアリングして、エージェントのチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", "subtitle": "この terminal は認識されたコーディング agent を実行していません。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ea5a458974d..b98e3a89799 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -31,6 +31,10 @@ "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." + }, + "menuBarIcon": { + "title": "메뉴 바 아이콘 표시", + "description": "macOS 메뉴 바에 Orca 바로가기 및 활동 표시기를 유지합니다." } } }, @@ -67,7 +71,8 @@ "quit": "종료", "minimizeNotice": { "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" - } + }, + "activityWaiting": "Orca - 활동 대기 중" }, "worktreeJumpPalette": { "matchLabel": { @@ -13196,6 +13201,7 @@ "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." }, + "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", "subtitle": "이 terminal은 인식된 코딩 agent를 실행하고 있지 않습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 95c2734f082..3ec3512d6c4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -31,6 +31,10 @@ "portsToggleDescription": "显示实时工作区端口。单击可查看工作区范围的端口和外部监听器。", "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" + }, + "menuBarIcon": { + "title": "显示菜单栏图标", + "description": "在 macOS 菜单栏中保留 Orca 快捷方式和活动指示器。" } } }, @@ -67,7 +71,8 @@ "quit": "退出", "minimizeNotice": { "body": "Orca 仍在系统托盘中运行" - } + }, + "activityWaiting": "Orca - 活动等待中" }, "worktreeJumpPalette": { "matchLabel": { @@ -13196,6 +13201,7 @@ "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。" }, + "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", "subtitle": "此终端未运行可识别的编码智能体。" diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index c553ceb02f3..1728c8ab5ca 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -112,7 +112,11 @@ describe('getActivityThreadTaskTitle', () => { }) it('keeps orchestration labels across terse follow-ups but yields to new work', () => { - const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' } + const orchestration = { + taskId: 'task-1', + dispatchId: 'ctx-1', + displayName: 'Fix checkout race' + } // Terse follow-up → still the same orchestration task. expect( getActivityThreadTaskTitle({ diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 141da5c2a3a..aaf3fc4795c 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -105,36 +105,9 @@ export function getWorkingAgentsPerWorktree({ return result } -const WELL_KNOWN_LABELS: Record = { - claude: 'Claude', - openclaude: 'OpenClaude', - codex: 'Codex', - gemini: 'Gemini', - antigravity: 'Antigravity', - amp: 'Amp', - copilot: 'GitHub Copilot', - opencode: 'OpenCode', - 'mimo-code': 'MiMo Code', - cursor: 'Cursor', - aider: 'Aider', - pi: 'Pi', - omp: 'OMP', - droid: 'Droid', - 'command-code': 'Command Code', - grok: 'Grok', - hermes: 'Hermes', - devin: 'Devin', - ante: 'Ante', - kimi: 'Kimi' -} - -export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { - if (!agentType || agentType === 'unknown') { - return 'Agent' - } - // Capitalize well-known names nicely; pass through custom names as-is - return WELL_KNOWN_LABELS[agentType] ?? agentType -} +// Re-exported from shared so mobile shows the same agent labels (one source of +// truth). Kept re-exported here so existing `@/lib/agent-status` importers work. +export { formatAgentTypeLabel } from '../../../shared/agent-type-label' // Why: AgentIcon expects a TuiAgent, but AgentType is a broader union // (WellKnownAgentType | (string & {})) that includes 'unknown' and arbitrary diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index a376fe0d5ec..84135bba8f4 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,19 +1,4 @@ -import type { AgentType } from '../../../shared/agent-status-types' -import type { TuiAgent } from '../../../shared/types' - -/** Agents whose transcripts the native chat view can parse and render. */ -export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex', - // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and - // Orca already reads that for hooks/AI Vault — native chat reuses the same - // path + a Grok JSONL decoder rather than leaving Grok TUI-only. - 'grok' -]) - -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { + isNativeChatSupportedAgent, + NATIVE_CHAT_SUPPORTED_AGENTS +} from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts new file mode 100644 index 00000000000..a863b503fe6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + capturedPointerIds: Set + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + flushAnimationFrames: () => void +} + +function createPaneElement(width: number): PaneElement { + return { + style: {}, + classList: { contains: vi.fn(() => false) }, + dispatchEvent: vi.fn(() => true), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + right: width, + bottom: 200, + width, + height: 200 + })), + querySelectorAll: vi.fn(() => []) + } as unknown as PaneElement +} + +function createPointerEvent(args: Partial): PointerEvent { + return { + preventDefault: vi.fn(), + pointerId: 1, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + const previousPane = createPaneElement(100) + const nextPane = createPaneElement(300) + const divider = { + style: { setProperty: vi.fn() }, + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (dividerListeners.get(event) === listener) { + dividerListeners.delete(event) + } + }), + setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (windowListeners.get(event) === listener) { + windowListeners.delete(event) + } + }) + }) + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + animationFrames.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => animationFrames.delete(frameId)) + ) + const onLayoutChanged = vi.fn() + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) + + return { + divider, + dividerListeners, + windowListeners, + capturedPointerIds, + previousPane, + nextPane, + onLayoutChanged, + flushAnimationFrames: () => { + for (const [frameId, callback] of animationFrames) { + animationFrames.delete(frameId) + callback(16) + } + } + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('divider pointer capture loss', () => { + it('continues through window events and commits the final drag position', () => { + const harness = createDividerDragHarness() + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.dividerListeners.has('lostpointercapture')).toBe(false) + + harness.capturedPointerIds.delete(9) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + harness.flushAnimationFrames() + harness.windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + + expect(harness.previousPane.style.flex).toBe('220 1 0%') + expect(harness.nextPane.style.flex).toBe('180 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('still restores the original layout when the window loses focus', () => { + const harness = createDividerDragHarness() + harness.previousPane.style.flex = '2 1 0%' + harness.nextPane.style.flex = '3 1 0%' + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + harness.windowListeners.get('blur')?.({} as Event) + + expect(harness.previousPane.style.flex).toBe('2 1 0%') + expect(harness.nextPane.style.flex).toBe('3 1 0%') + expect(harness.onLayoutChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 0b552ccc4ca..8fd5d3443ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -93,6 +93,8 @@ export function attachDividerDrag( if (windowListenersAttached || typeof window === 'undefined') { return } + // Why: Chromium can transiently lose capture while the button remains held, + // so window events keep ownership until pointerup, pointercancel, or blur. windowListenersAttached = true window.addEventListener('pointermove', onPointerMove, true) window.addEventListener('pointerup', onPointerUp, true) @@ -264,12 +266,6 @@ export function attachDividerDrag( } } - const onLostPointerCapture = (): void => { - if (dragging) { - finishActiveDrag(false) - } - } - const onWindowBlur = (): void => { finishActiveDrag(false) } @@ -278,7 +274,6 @@ export function attachDividerDrag( divider.addEventListener('pointermove', onPointerMove) divider.addEventListener('pointerup', onPointerUp) divider.addEventListener('pointercancel', onPointerCancel) - divider.addEventListener('lostpointercapture', onLostPointerCapture) divider.addEventListener('dblclick', onDoubleClick) dividerDragCleanups.set(divider, () => { finishActiveDrag(false) @@ -286,7 +281,6 @@ export function attachDividerDrag( divider.removeEventListener('pointermove', onPointerMove) divider.removeEventListener('pointerup', onPointerUp) divider.removeEventListener('pointercancel', onPointerCancel) - divider.removeEventListener('lostpointercapture', onLostPointerCapture) divider.removeEventListener('dblclick', onDoubleClick) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index b4675047d68..e9c891baabc 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -9,6 +9,7 @@ import { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } from './terminal-scroll-intent-rebuild' +import { safeFitAndThen } from './pane-tree-ops' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -178,6 +179,22 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('releases a hidden reattach continuation when the stable grid already matches', async () => { + const pane = createPane() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + const pending = safeFitAndThen(pane, 'reattach-pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 79, rows: 24 }) + requestStablePaneFit(pane) + flushAnimationFrames() + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { const onSettled = vi.fn() const pane = createPane() diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index 16d1a0a5b15..53d2f6b6f11 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -74,13 +74,11 @@ function flushStableFitCallbacks(pane: StableFitPane): void { } } -function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { +function finishStableFit(pane: StableFitPane): void { setPendingObservedFitRafId(pane, null) - if (shouldFit) { - safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) - return - } - flushStableFitCallbacks(pane) + // Why: an equal grid still proves a restored pane is measurable, so it must + // release reattach continuations that were parked while the tab was hidden. + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) } export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void): void { @@ -113,23 +111,23 @@ export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void frameCount += 1 if (!next) { - finishStableFit(pane, true) + finishStableFit(pane) return } if (terminalDimensionsEqual(pane, next)) { - finishStableFit(pane, false) + finishStableFit(pane) return } if (dimensionsEqual(previous, next)) { - finishStableFit(pane, true) + finishStableFit(pane) return } previous = next if (frameCount >= MAX_STABILITY_FRAMES) { - finishStableFit(pane, true) + finishStableFit(pane) return } diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index db06aaf0e6c..e46fa1b66d7 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -91,4 +91,137 @@ describe('workspace session live PTY persistence', () => { expect(payload.activeWorktreeIdsOnShutdown).toEqual([]) expect(payload.remoteSessionIdsByTabId).toBeUndefined() }) + + it('reconnects a persisted SSH PTY when shutdown observes a transient relay drop', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // Why 'reconnecting': an involuntary transport drop always lands in + // 'reconnecting' / 'reconnection-failed' / 'error' — never + // 'disconnected', which only an explicit user disconnect produces. + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + + it('keeps explicitly disconnected hosts out of the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // 'disconnected' = the user chose to take this host offline. The + // session id must still persist (restore-on-focus), but startup must + // not auto-dial the host against the user's intent. + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) + + it('never derives runtime-owned SSH targets into the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-vm': [ + { + id: 'tab-vm', + title: 'vm', + ptyId: null, + worktreeId: 'wt-vm' + } as never + ] + }, + ptyIdsByTabId: { 'tab-vm': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' }, + // Why a status entry: without one the status gate already excludes the + // target and the runtime-owned check would be untested dead weight. + // A pane-driven optimistic write CAN stamp runtime-owned states + // (TerminalSshReconnectOverlay), so pin the exclusion independently — + // on both the session-id union path ('reconnecting') and the live + // connected-states path ('connected'). + sshConnectionStates: new Map([ + ['runtime-ssh-vm1', { status: 'reconnecting' } as never], + ['runtime-ssh-vm2', { status: 'connected' } as never] + ]), + repos: [ + { + id: 'repo-vm', + path: '/repo-vm', + displayName: 'VM', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'runtime-ssh-vm1' + } + ], + worktreesByRepo: { + 'repo-vm': [{ id: 'wt-vm', repoId: 'repo-vm' } as never] + } + }) + ) + + // Why: the renderer must never drive startup ssh.connect for runtime-owned + // (ephemeral-VM) targets — their lifecycle belongs to the runtime layer, + // and ssh.listTargets() hides them so the connect would target a host the + // user cannot see or manage. + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index ec4ea11e643..da578c1ff42 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -205,6 +205,10 @@ describe('buildWorkspaceSessionPatch', () => { expect(Object.keys(patch).sort()).toEqual( [ + // Why: the reconnect list derives from remote session ids, so any + // patch that rewrites remoteSessionIdsByTabId must carry it too — + // otherwise a crash between patches strands a stale target on disk. + 'activeConnectionIdsAtShutdown', 'activeWorktreeIdsOnShutdown', 'remoteSessionIdsByTabId', 'tabsByWorktree', @@ -225,6 +229,25 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.activeConnectionIdsAtShutdown).toBeUndefined() }) + it('derives reconnect targets from surviving relay session ids in terminal-field patches', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [createRepo('repo-ssh', 'conn-1')], + worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] } + }), + ['lastKnownRelayPtyIdByTabId'] + ) + + expect(patch.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'ssh:conn-1@@pty-42' }) + expect(patch.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + it('patches tab chrome as a sanitized bundle when split groups change', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index eff46edfa23..e88f476a8f6 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -72,10 +72,21 @@ export function buildWorkspaceSessionPatch( 'worktreesByRepo' ] as const) ) { - Object.assign(patch, buildTerminalSessionData(snapshot)) - } - if (changed.has('sshConnectionStates')) { - patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown(snapshot) + const terminalSessionData = buildTerminalSessionData(snapshot) + Object.assign(patch, terminalSessionData) + // Why: the reconnect list is derived from persisted remote session ids as + // well as live SSH state. Recompute it alongside remoteSessionIdsByTabId + // so a crash between patches cannot leave a target on disk whose sessions + // were all closed (or vice versa). + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ) + } else if (changed.has('sshConnectionStates')) { + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + buildTerminalSessionData(snapshot).remoteSessionIdsByTabId ?? null + ) } if ( hasAnyChangedField(changed, [ diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts new file mode 100644 index 00000000000..713e4388e53 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -0,0 +1,47 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import type { WorkspaceSessionSnapshot } from './workspace-session' + +export function buildActiveConnectionIdsAtShutdown( + snapshot: WorkspaceSessionSnapshot, + // Why: required (null = none) rather than defaulted — every caller already + // has the terminal session data in hand, and a default initializer would + // re-run the full repo/worktree scan on the shutdown-critical path. + remoteSessionIdsByTabId: WorkspaceSessionState['remoteSessionIdsByTabId'] | null +): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { + // Why: sshConnectionStates is a Map, not a plain + // object. Object.entries() on a Map returns [] — must use Array.from(). + // Runtime-owned states are normally never broadcast to the renderer, but a + // pane-level optimistic write could stamp one; exclude them here too. + const targetIds = new Set( + Array.from(snapshot.sshConnectionStates.entries()) + .filter( + ([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId) + ) + .map(([targetId]) => targetId) + ) + + // Why: shutdown can observe SSH in a transient state (relay drop mid-quit, + // exhausted reconnect) after the socket closed but before the snapshot + // flushed. The durable PTY id still names the target Orca must reconnect to + // restore that surviving remote session. Two exclusions: + // 'disconnected'/'auth-failed'/never-observed usually mean an explicit user + // disconnect or a failed/cancelled connect — startup must not auto-dial a + // host the user left offline or stack credential dialogs (sessions still + // restore on tab focus via the deferred flow, so only eagerness is lost). + // Runtime-owned (ephemeral-VM) targets belong to the runtime layer; a + // renderer-driven ssh.connect would dispose the runtime's live relay session. + for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) { + const connectionId = parseAppSshPtyId(sessionId)?.connectionId + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + continue + } + const status = snapshot.sshConnectionStates.get(connectionId)?.status + if (status && status !== 'disconnected' && status !== 'auth-failed') { + targetIds.add(connectionId) + } + } + + return targetIds.size > 0 ? Array.from(targetIds) : undefined +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f0acc22e620..23cc4da80e0 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -12,6 +12,9 @@ import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' +import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' + +export { buildActiveConnectionIdsAtShutdown } /** Why (issue #1158): the debounced + shutdown session writers share this * gate so a hydration failure cannot overwrite orca-data.json with the @@ -331,18 +334,6 @@ export function buildTerminalSessionData( } } -export function buildActiveConnectionIdsAtShutdown( - snapshot: WorkspaceSessionSnapshot -): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { - // Why: sshConnectionStates is a Map, not a plain - // object. Object.entries() on a Map returns [] — must use Array.from(). - const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries()) - .filter(([, state]) => state.status === 'connected') - .map(([targetId]) => targetId) - - return connectedTargetIds.length > 0 ? connectedTargetIds : undefined -} - export function buildWorkspaceSessionPayload( snapshot: WorkspaceSessionSnapshot ): WorkspaceSessionState { @@ -380,7 +371,10 @@ export function buildWorkspaceSessionPayload( // Persist only layouts backed by real tabs so a reload cannot restore a // blank split pane from that transient midpoint. ...buildPersistedUnifiedTabSessionData(snapshot), - activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown(snapshot), + activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ), remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: per-worktree focus-recency for Cmd+J's empty-query ordering. // Omit when empty so sessions written by builds that never stamped diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d2844272c8f..d6f6d6e5830 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1091,41 +1091,114 @@ function createNativeChatApi(): NativeChatApi { limit, transcriptPath }), - subscribe: (args, onAppended) => { + subscribe: (args, onFrame) => { // No paired runtime yet: nothing to subscribe to, and // requireActiveEnvironment() would throw. Return a no-op teardown so the // chat view mounts cleanly until a runtime is paired (only the not-paired // case is swallowed — real subscribe errors still surface via .catch). const environment = requireActiveEnvironmentOrNull() if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) return () => {} } let handle: { unsubscribe: () => void } | null = null let cancelled = false + let receivedInitial = false void getClientForEnvironment(environment) .subscribe( 'nativeChat.subscribe', - { agent: args.agent, sessionId: args.sessionId, transcriptPath: args.transcriptPath }, + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, { onResponse: (response) => { - if (cancelled || !response.ok) { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } return } const result = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (result?.type === 'appended' && Array.isArray(result.messages)) { - onAppended(result.messages) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false + } + : { type: 'appended', messages: result.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize would + // otherwise never flip receivedInitial, stranding the view on + // 'loading'. Settle it with an empty snapshot (carrying any error + // the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) } } }, { // Why: send nativeChat.unsubscribe on teardown so the server reaps // the transcript fs-watcher on view-toggle, not just on socket close - // (the watcher-leak fix). Uses the same agent:sessionId cleanup token - // mobile sends, via the shared key-builder so it can't drift. - buildUnsubscribe: () => buildNativeChatUnsubscribe(args.agent, args.sessionId) + // (the watcher-leak fix). Echo the pane-specific token so two panes + // watching one session cannot tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) } ) .then((h) => { @@ -1135,7 +1208,17 @@ function createNativeChatApi(): NativeChatApi { handle = h } }) - .catch(() => {}) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) return () => { cancelled = true handle?.unsubscribe() @@ -2379,6 +2462,9 @@ function createWebUiApi(): NonNullable['ui']> { }, isMaximized: () => Promise.resolve(false), onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there is never a + // queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts new file mode 100644 index 00000000000..b638334f633 --- /dev/null +++ b/src/shared/agent-type-label.ts @@ -0,0 +1,34 @@ +import type { AgentType } from './agent-status-types' + +// Shared so the desktop renderer and the mobile app show the same agent name +// (e.g. native chat's empty state on both surfaces) from one source of truth. +const WELL_KNOWN_LABELS: Record = { + claude: 'Claude', + openclaude: 'OpenClaude', + codex: 'Codex', + gemini: 'Gemini', + antigravity: 'Antigravity', + amp: 'Amp', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', + cursor: 'Cursor', + aider: 'Aider', + pi: 'Pi', + omp: 'OMP', + droid: 'Droid', + 'command-code': 'Command Code', + grok: 'Grok', + hermes: 'Hermes', + devin: 'Devin', + ante: 'Ante', + kimi: 'Kimi' +} + +export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { + if (!agentType || agentType === 'unknown') { + return 'Agent' + } + // Capitalize well-known names nicely; pass through custom names as-is + return WELL_KNOWN_LABELS[agentType] ?? agentType +} diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e..c7ac1423e10 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -3,6 +3,17 @@ import { describe, expect, it } from 'vitest' import { buildAiVaultResumeCommand } from './ai-vault-types' describe('buildAiVaultResumeCommand', () => { + it('uses Antigravity conversation ids instead of Gemini resume flags', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'antigravity', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && agy --conversation 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a self-contained cmd wrapper when no live shell is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index fe07aca6963..dd780ef9ac4 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'omp', 'cursor', 'gemini', + 'antigravity', 'rovo', 'copilot', 'opencode', @@ -43,6 +44,7 @@ export const AI_VAULT_AGENT_LABELS = { omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', + antigravity: 'Antigravity', rovo: 'Rovo Dev', copilot: 'GitHub Copilot', opencode: 'OpenCode', @@ -323,6 +325,8 @@ function buildAgentResumeInvocation( // but the `--resume ` invocation form is identical to the others here. case 'omp': return `${baseCommand} --resume ${sessionArg}` + case 'antigravity': + return `${baseCommand} --conversation ${sessionArg}` } } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 343e0ffe173..8a167b08d41 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -48,6 +48,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').uiLanguage).toBe('system') }) + it('defaults the menu bar icon on so the value round-trips across platforms', () => { + expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true) + }) + it('confirms before closing pinned tabs by default', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 22064cbe6ca..0573c89ec93 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -266,6 +266,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalFocusFollowsMouse: false, windowBackgroundBlur: false, minimizeToTrayOnClose: false, + // Why: default-on everywhere so the value round-trips across platforms; + // only the darwin consumers act on it. + showMenuBarIcon: true, terminalClipboardOnSelect: false, // Why: OSC 52 is a classic data-exfiltration vector (any process piping // untrusted output into the terminal can rewrite the clipboard). Keep the diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts new file mode 100644 index 00000000000..8745d47b394 --- /dev/null +++ b/src/shared/native-chat-agent-support.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + isNativeChatSupportedAgent, + resolveNativeChatTranscriptAgent, + shouldStepNativeChatAskAnswer +} from './native-chat-agent-support' + +describe('resolveNativeChatTranscriptAgent', () => { + it('maps OpenClaude onto the Claude transcript format', () => { + expect(resolveNativeChatTranscriptAgent('openclaude')).toBe('claude') + expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') + }) + + it('passes codex and grok through and rejects everything else', () => { + expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') + expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') + expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() + expect(resolveNativeChatTranscriptAgent(null)).toBeNull() + expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() + }) +}) + +describe('isNativeChatSupportedAgent', () => { + it('recognizes the parseable agents and rejects unknown / nullish input', () => { + expect(isNativeChatSupportedAgent('claude')).toBe(true) + expect(isNativeChatSupportedAgent('openclaude')).toBe(true) + expect(isNativeChatSupportedAgent('cursor')).toBe(false) + expect(isNativeChatSupportedAgent(null)).toBe(false) + expect(isNativeChatSupportedAgent(undefined)).toBe(false) + }) +}) + +describe('shouldStepNativeChatAskAnswer', () => { + it('steps only the Claude-format agents (Claude, OpenClaude)', () => { + expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) + expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true) + }) + + it('does not step other or unknown agents', () => { + expect(shouldStepNativeChatAskAnswer('codex')).toBe(false) + expect(shouldStepNativeChatAskAnswer('grok')).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 new file mode 100644 index 00000000000..0c7e8e4a14d --- /dev/null +++ b/src/shared/native-chat-agent-support.ts @@ -0,0 +1,34 @@ +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' + +/** Agents whose transcripts the native chat view can parse and render. */ +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ + 'claude', + 'openclaude', + 'codex', + 'grok' +]) + +export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { + return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) +} + +/** True when the agent renders Claude's multi-step AskUserQuestion — one question + * per step, each Enter advancing — so a multi-line answer must be paced per line. + * Other agents submit the whole answer with a single Enter. */ +export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'claude' +} + +export function resolveNativeChatTranscriptAgent( + agent: string | null | undefined +): NativeChatTranscriptAgent | null { + // Why: OpenClaude writes the Claude transcript format and layout even though + // Orca preserves its distinct agent identity for launch and UI behavior. + if (agent === 'claude' || agent === 'openclaude') { + return 'claude' + } + if (agent === 'codex' || agent === 'grok') { + return agent + } + return null +} diff --git a/src/shared/native-chat-answer-stepping.ts b/src/shared/native-chat-answer-stepping.ts new file mode 100644 index 00000000000..0fd148e13e8 --- /dev/null +++ b/src/shared/native-chat-answer-stepping.ts @@ -0,0 +1,26 @@ +export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 +// 500ms (not a tighter cadence) so the next AskUserQuestion step still renders +// before its body is written on slower machines / under SSH round-trip latency. +export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 +export const NATIVE_CHAT_QUESTION_STEP_MS = + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS + +export function nativeChatQuestionOffsets(index: number): { bodyAt: number; enterAt: number } { + const bodyAt = index * NATIVE_CHAT_QUESTION_STEP_MS + return { bodyAt, enterAt: bodyAt + NATIVE_CHAT_SUBMIT_DELAY_MS } +} + +/** Schedule one body and one Enter per question; callers own the actual transport. */ +export function scheduleNativeChatAnswer( + lines: readonly string[], + writeBody: (line: string) => void, + writeEnter: () => void +): ReturnType[] { + const timers: ReturnType[] = [] + lines.forEach((line, index) => { + const { bodyAt, enterAt } = nativeChatQuestionOffsets(index) + timers.push(setTimeout(() => writeBody(line), bodyAt)) + timers.push(setTimeout(writeEnter, enterAt)) + }) + return timers +} diff --git a/src/shared/native-chat-ask-types.ts b/src/shared/native-chat-ask-types.ts index 640f2ed74d7..9c5942ab7de 100644 --- a/src/shared/native-chat-ask-types.ts +++ b/src/shared/native-chat-ask-types.ts @@ -1,8 +1,5 @@ -// Canonical AskUserQuestion prompt types shared by both native-chat surfaces -// (desktop `native-chat-interactive-prompt.ts` and mobile `mobile-native-chat-ask.ts`). -// Types only — both surfaces `import type` from here (Metro can't resolve runtime -// values from src/shared), so the type declarations stop being duplicated while -// the parser bodies stay mirrored and parity-tested. +// Canonical AskUserQuestion prompt types consumed by the shared parser and both +// native-chat platform UIs. export type AskOption = { label: string; description?: string } export type AskQuestion = { diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts new file mode 100644 index 00000000000..e02f2bb9bba --- /dev/null +++ b/src/shared/native-chat-ask.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './native-chat-ask' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } +} + +function call(name: string, input: unknown): NativeChatBlock { + return { type: 'tool-call', name, input } +} + +function result(): NativeChatBlock { + return { type: 'tool-result', output: 'ok' } +} + +const QUESTIONS_INPUT = { + questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }] +} + +describe('extractPendingAsk', () => { + it('recognizes an unregistered tool whose input matches the canonical questions shape', () => { + // The live path (parseAskFromStatus) accepts this shape from any tool name; + // transcript replay must not silently drop the same pending question. + const pending = extractPendingAsk([message('m1', [call('CustomAskTool', QUESTIONS_INPUT)])]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('resolves calls FIFO so a sibling result cannot clear a newer pending ask', () => { + const pending = extractPendingAsk([ + message('m1', [ + call('Bash', { command: 'ls' }), + call('AskUserQuestion', QUESTIONS_INPUT), + // FIFO: this result answers the Bash call, not the ask. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it("clears the ask when its own result arrives, keeping the newest ask's identity", () => { + const first = { questions: [{ question: 'First?', options: [] }] } + const pending = extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', first), + call('AskUserQuestion', QUESTIONS_INPUT), + // Resolves the FIRST ask (FIFO); the newer one stays pending. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('ignores malformed question payloads', () => { + expect( + extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', { questions: [] }), + call('AskUserQuestion', { questions: [{}] }), + call('AskUserQuestion', 'not-an-object') + ]) + ]) + ).toBeNull() + }) +}) + +describe('parseAskFromStatus', () => { + it('accepts the canonical shape from any tool name and rejects broken JSON', () => { + expect( + parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions + ).toHaveLength(1) + expect(parseAskFromStatus('{not json', 'AskUserQuestion')).toBeNull() + expect(parseAskFromStatus(null)).toBeNull() + }) + + it('parses string options into labels', () => { + const prompt = parseAskFromStatus( + JSON.stringify({ questions: [{ question: 'Pick', options: ['a', 'b'] }] }) + ) + expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b']) + }) +}) diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts new file mode 100644 index 00000000000..567ed97e0d2 --- /dev/null +++ b/src/shared/native-chat-ask.ts @@ -0,0 +1,227 @@ +import type { + AskOption, + AskPrompt, + AskQuestion, + InteractiveQuestionParser +} from './native-chat-ask-types' +import type { NativeChatMessage } from './native-chat-types' + +export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } + +const QUESTION_TOOL_PARSERS = new Map() + +export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { + QUESTION_TOOL_PARSERS.set(toolName, parser) +} + +function parseQuestionsShape(input: unknown): AskPrompt | null { + if (!input || typeof input !== 'object') { + return null + } + const rawQuestions = (input as { questions?: unknown }).questions + if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { + return null + } + const questions: AskQuestion[] = [] + for (const raw of rawQuestions) { + if (!raw || typeof raw !== 'object') { + continue + } + const question = raw as Record + const text = typeof question.question === 'string' ? question.question : '' + const options = parseOptions(question.options) + if (text || options.length > 0) { + questions.push({ + question: text, + header: typeof question.header === 'string' ? question.header : undefined, + multiSelect: question.multiSelect === true, + options + }) + } + } + return questions.length > 0 ? { questions } : null +} + +function parseOptions(raw: unknown): AskOption[] { + if (!Array.isArray(raw)) { + return [] + } + return raw + .map((option): AskOption | null => { + if (typeof option === 'string') { + return { label: option } + } + if ( + option && + typeof option === 'object' && + typeof (option as { label?: unknown }).label === 'string' + ) { + const value = option as { label: string; description?: unknown } + return { + label: value.label, + description: typeof value.description === 'string' ? value.description : undefined + } + } + return null + }) + .filter((option): option is AskOption => option !== null) +} + +for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { + QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) +} + +function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { + const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined + return (parser ? parser(input) : null) ?? parseQuestionsShape(input) +} + +export function parseAskFromStatus( + interactivePrompt: string | undefined | null, + toolName?: string +): AskPrompt | null { + if (!interactivePrompt) { + return null + } + try { + return parseToolInput(toolName, JSON.parse(interactivePrompt)) + } catch { + return null + } +} + +/** Resolve the newest question tool that has not received its FIFO tool result. + * Transcript replay parses each tool-call through the same registered-parser + + * canonical-shape fallback as live status, so a question tool that rendered + * live cannot vanish from pending state after a reconnect/replay. */ +export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null { + let pending: AskPrompt | null = null + const outstanding: (AskPrompt | null)[] = [] + for (const message of messages) { + for (const block of message.blocks) { + if (block.type === 'tool-call') { + const parsed = parseToolInput(block.name, block.input) + if (parsed) { + pending = parsed + } + outstanding.push(parsed) + } else if (block.type === 'tool-result' && outstanding.length > 0) { + const resolved = outstanding.shift() + if (resolved && resolved === pending) { + pending = null + } + } + } + } + return pending +} + +/** One question's chosen answer, normalized for delivery: the selected option + * indices (in option order) plus any free-text "other" answer. Index-based (not + * label text) so the answer can be delivered by the selector's stable option + * number — see `buildAskAnswerKeys`. */ +export type AskAnswerSelection = { indices: number[]; other?: string } + +/** A single keystroke group to write to the agent PTY. `raw` bytes (option + * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a + * free-text answer the caller runs through its paste sanitizer before writing. */ +export type AskAnswerKeyGroup = { raw: string } | { text: string } + +/** True when this question is answered (a picked option or typed free text). */ +function isAnswered(sel: AskAnswerSelection | undefined): boolean { + return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 +} + +/** The picked labels + trimmed free text for one question, in option order. */ +function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { + const labels = (sel?.indices ?? []) + .map((i) => question.options[i]?.label ?? '') + .filter((l) => l.length > 0) + const other = (sel?.other ?? '').trim() + return other ? [...labels, other] : labels +} + +/** Build the human-readable answer text: one line per question, in question + * order, each the selected label(s) + free text joined by ", ". Empty answers + * stay empty lines so N lines always == N questions. Used for agents whose + * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ +export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { + return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') +} + +// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits +// the HIGHLIGHTED default (the first option), and pasted label text does not move +// the highlight — so answering by label silently delivered every non-first pick +// as the first option (STA-1860). Instead we drive the selector by each option's +// stable 1-based number (which matches the card's badge), the marker it commits +// on. Right-arrow steps to the next question / the Submit tab. Verified live +// against Claude Code's TUI; groups are written spaced apart (see the senders) +// because a navigation keystroke batched with Enter commits before the selector +// has applied it. +const ASK_ENTER = '\r' +const ASK_NEXT_TAB = '\x1b[C' + +/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. + * Each group is written a step apart so the selector applies it before the next. + * + * - single-select pick → the option number (selects AND commits; in a + * multi-question prompt it auto-advances to the next question) + * - free-text answer → the "Type something" row number, the text, then Enter + * - multi-select → each option number TOGGLES its checkbox, then a step + * to the Submit tab + * - a multi-question prompt (and a lone multi-select) finishes on a Submit + * confirmation, so it ends with one Enter + * + * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always + * address every row.) */ +export function buildAskAnswerKeys( + prompt: AskPrompt, + selections: AskAnswerSelection[] +): AskAnswerKeyGroup[] { + const questions = prompt.questions + const multiQuestion = questions.length > 1 + const groups: AskAnswerKeyGroup[] = [] + + questions.forEach((q, qi) => { + const sel = selections[qi] + const other = (sel?.other ?? '').trim() + const typeSomething = String(q.options.length + 1) + + if (q.multiSelect) { + for (const i of sel?.indices ?? []) { + groups.push({ raw: String(i + 1) }) + } + if (other) { + groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) + } + // A multi-select never auto-advances; step to the next tab (the Submit tab + // when this is the last question). + groups.push({ raw: ASK_NEXT_TAB }) + } else if (other) { + // Single-select can only carry one value, so route any answer that + // includes free text through the "Type something" row as one string. + groups.push( + { raw: typeSomething }, + { text: answerLabels(q, sel).join(', ') }, + { raw: ASK_ENTER } + ) + } else if ((sel?.indices.length ?? 0) > 0) { + groups.push({ raw: String(sel!.indices[0]! + 1) }) + } else if (multiQuestion) { + // Unanswered question in a multi-question prompt: step past it. + groups.push({ raw: ASK_NEXT_TAB }) + } + }) + + const endsOnSubmitTab = + multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) + if (endsOnSubmitTab && groups.length > 0) { + groups.push({ raw: ASK_ENTER }) + } + return groups +} + +/** Whether any question in `selections` carries an answer worth submitting. */ +export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { + return prompt.questions.some((_, i) => isAnswered(selections[i])) +} diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts new file mode 100644 index 00000000000..1e49d1e8969 --- /dev/null +++ b/src/shared/native-chat-diff.ts @@ -0,0 +1,85 @@ +export type NativeChatDiffLineKind = 'add' | 'del' | 'context' | 'meta' + +export type NativeChatDiffLine = { + kind: NativeChatDiffLineKind + text: string +} + +const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) +const MAX_DIFF_CHARS = 32_000 +const DEFAULT_MAX_DIFF_LINES = 120 +const DIFF_TRUNCATED_LINE: NativeChatDiffLine = { + kind: 'meta', + text: '… diff truncated …' +} + +function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } { + if (typeof value !== 'string') { + return { lines: [], truncated: false } + } + const clipped = value.slice(0, MAX_DIFF_CHARS) + const lines = clipped.split('\n', maxLines + 1) + const truncated = value.length > MAX_DIFF_CHARS || lines.length > maxLines + const bounded = lines.slice(0, maxLines) + if (!truncated && bounded.at(-1) === '') { + bounded.pop() + } + return { lines: bounded, truncated } +} + +export function diffFromToolCall( + name: string, + input: unknown, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { + return null + } + const value = input as Record + const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines) + const newLines = toLines( + value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text, + maxLines + ) + const deleted = oldLines.lines.map((text): NativeChatDiffLine => ({ kind: 'del', text })) + const added = newLines.lines.map((text): NativeChatDiffLine => ({ kind: 'add', text })) + if (deleted.length === 0 && added.length === 0) { + return null + } + const path = value.file_path ?? value.path + const prefix: NativeChatDiffLine[] = + typeof path === 'string' ? [{ kind: 'meta', text: path }] : [] + const combined = [...prefix, ...deleted, ...added] + const truncated = oldLines.truncated || newLines.truncated || combined.length > maxLines + return truncated ? [...combined.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : combined +} + +export function diffFromText( + text: string, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (text.length === 0) { + return null + } + const bounded = toLines(text, maxLines) + let added = 0 + let removed = 0 + const lines = bounded.lines.map((line): NativeChatDiffLine => { + if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { + return { kind: 'meta', text: line } + } + if (line.startsWith('+') && !line.startsWith('+++')) { + added += 1 + return { kind: 'add', text: line.slice(1) } + } + if (line.startsWith('-') && !line.startsWith('---')) { + removed += 1 + return { kind: 'del', text: line.slice(1) } + } + return { kind: 'context', text: line } + }) + if (added + removed < 2) { + return null + } + return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines +} diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts new file mode 100644 index 00000000000..2797648afd0 --- /dev/null +++ b/src/shared/native-chat-empty-state.ts @@ -0,0 +1,39 @@ +// Canonical English copy for the native-chat empty/loading/error states, shared +// by the desktop renderer (as its i18n fallback strings) and the mobile app +// (used directly — mobile ships English only) so the two surfaces never drift. +// `{{value0}}` is the agent label; each caller substitutes it (i18n on desktop, +// `formatNativeChatEmptyStateCopy` on mobile). + +export type NativeChatEmptyStateCopy = { title: string; subtitle: string } + +export const NATIVE_CHAT_EMPTY_STATE_COPY = { + loading: { + title: 'Loading conversation…', + subtitle: 'Reading the agent transcript.' + }, + empty: { + title: 'Start a chat with {{value0}}', + subtitle: 'Ask {{value0}} to inspect code, explain output, or make a change.' + }, + error: { + title: 'Could not load conversation', + subtitle: 'The transcript could not be read. Toggle back to the terminal to keep working.' + }, + notAgent: { + title: 'No conversation here', + subtitle: 'This terminal is not running a recognized coding agent.' + } +} as const satisfies Record + +/** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. + * For platforms without an i18n layer (mobile). */ +export function formatNativeChatEmptyStateCopy( + kind: keyof typeof NATIVE_CHAT_EMPTY_STATE_COPY, + agentLabel: string +): NativeChatEmptyStateCopy { + const copy = NATIVE_CHAT_EMPTY_STATE_COPY[kind] + return { + title: copy.title.replaceAll('{{value0}}', agentLabel), + subtitle: copy.subtitle.replaceAll('{{value0}}', agentLabel) + } +} diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index d662fec3891..a2e12ad8302 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,13 +1,10 @@ -// Pure id-dedup merge for native-chat message windows, shared by the desktop -// renderer live path. Mobile keeps a parity-locked twin of this algorithm -// (mobile/src/session/mobile-native-chat-merge.ts) because Metro can't resolve -// runtime values outside the mobile package — the cross-surface parity test -// (native-chat-merge-parity.test.ts) pins the two implementations together. -// -// The algorithm is parameterized on a source-priority map so the single body -// works for any caller that supplies NATIVE_CHAT_SOURCE_PRIORITY. +// Pure id-dedup and windowing for both desktop and mobile native-chat streams. -import type { NativeChatMessage, NativeChatSource } from './native-chat-types' +import { + NATIVE_CHAT_SOURCE_PRIORITY, + type NativeChatMessage, + type NativeChatSource +} from './native-chat-types' export type NativeChatSourcePriority = Record @@ -31,6 +28,13 @@ export function mergeNativeChatMessagesWith( return merged } +export function mergeNativeChatMessages( + existing: readonly NativeChatMessage[], + incoming: readonly NativeChatMessage[] +): NativeChatMessage[] { + return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) +} + /** Cap a message list to its most-recent `limit` entries. The base read is * already windowed; this keeps the live-append tail bounded to the same window * so a long run can't grow the list without limit. A non-positive limit means @@ -56,7 +60,9 @@ export type NativeChatMerger = { readonly priority: NativeChatSourcePriority } -export function createNativeChatMerger(priority: NativeChatSourcePriority): NativeChatMerger { +export function createNativeChatMerger( + priority: NativeChatSourcePriority = NATIVE_CHAT_SOURCE_PRIORITY +): NativeChatMerger { return { list: [], indexById: new Map(), priority } } @@ -73,13 +79,20 @@ export function replaceList(merger: NativeChatMerger, list: readonly NativeChatM * index incrementally — O(incoming), never re-scanning the existing list. */ export function applyAppend( merger: NativeChatMerger, - incoming: readonly NativeChatMessage[] + incoming: readonly NativeChatMessage[], + limit?: number ): NativeChatMessage[] { if (incoming.length === 0) { return merger.list } const next = [...merger.list] applyIncoming(next, merger.indexById, incoming, merger.priority) + const bounded = limit === undefined ? next : boundNativeChatWindow(next, limit) + if (bounded !== next) { + // Why: trimming shifts every cached index, so rebuild at the window boundary. + replaceList(merger, bounded) + return merger.list + } merger.list = next return next } diff --git a/src/shared/native-chat-noise.ts b/src/shared/native-chat-noise.ts new file mode 100644 index 00000000000..ef75c81f751 --- /dev/null +++ b/src/shared/native-chat-noise.ts @@ -0,0 +1,25 @@ +import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +function messageText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') + .trim() +} + +/** Harness-injected user/system turns are transport machinery, not conversation. */ +export function isNoiseMessage(message: NativeChatMessage): boolean { + if (message.role !== 'user' && message.role !== 'system') { + return false + } + if (message.blocks.some((block) => block.type === 'tool-call' || block.type === 'tool-result')) { + return false + } + return isKnownHarnessInjectedUserTurnText(messageText(message)) +} + +export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + return messages.filter((message) => !isNoiseMessage(message)) +} diff --git a/src/shared/native-chat-stream-unsubscribe.test.ts b/src/shared/native-chat-stream-unsubscribe.test.ts index 01c4c3e0db8..60f3cc26099 100644 --- a/src/shared/native-chat-stream-unsubscribe.test.ts +++ b/src/shared/native-chat-stream-unsubscribe.test.ts @@ -15,4 +15,11 @@ describe('native-chat stream unsubscribe key builder', () => { params: { subscriptionId: 'codex:abc' } }) }) + + it('echoes a pane-specific token when the subscriber supplied one', () => { + expect(buildNativeChatUnsubscribe('claude', 'same-session', 'pane-2')).toEqual({ + method: 'nativeChat.unsubscribe', + params: { subscriptionId: 'pane-2' } + }) + }) }) diff --git a/src/shared/native-chat-stream-unsubscribe.ts b/src/shared/native-chat-stream-unsubscribe.ts index 76475ba32cb..ae7c52587fe 100644 --- a/src/shared/native-chat-stream-unsubscribe.ts +++ b/src/shared/native-chat-stream-unsubscribe.ts @@ -18,10 +18,11 @@ export function buildNativeChatSubscriptionId(agent: string, sessionId: string): /** The unsubscribe RPC frame a client sends on teardown to close the watcher. */ export function buildNativeChatUnsubscribe( agent: string, - sessionId: string + sessionId: string, + subscriptionId?: string ): NativeChatUnsubscribeRpc { return { method: 'nativeChat.unsubscribe', - params: { subscriptionId: buildNativeChatSubscriptionId(agent, sessionId) } + params: { subscriptionId: subscriptionId ?? buildNativeChatSubscriptionId(agent, sessionId) } } } diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts new file mode 100644 index 00000000000..1b363a280fa --- /dev/null +++ b/src/shared/native-chat-tool-fold.ts @@ -0,0 +1,93 @@ +import { + isToolCallBlock, + isToolResultBlock, + type NativeChatBlock, + type NativeChatMessage, + type NativeChatToolCallBlock, + type NativeChatToolResultBlock +} from './native-chat-types' + +function isToolOnlyMessage(message: NativeChatMessage): boolean { + return ( + message.blocks.length > 0 && + message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) + ) +} + +/** Fold consecutive tool-only messages into their preceding assistant turn. */ +export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + const output: NativeChatMessage[] = [] + let mutableAssistantIndex = -1 + for (const message of messages) { + const previous = output.at(-1) + if (isToolOnlyMessage(message) && previous?.role === 'assistant') { + const index = output.length - 1 + if (mutableAssistantIndex !== index) { + output[index] = { ...previous, blocks: [...previous.blocks] } + mutableAssistantIndex = index + } + output[index]!.blocks.push(...message.blocks) + } else { + output.push(message) + mutableAssistantIndex = -1 + } + } + return output +} + +export type NativeChatToolPair = { + call?: NativeChatToolCallBlock + result?: NativeChatToolResultBlock +} + +/** Pair calls and results by FIFO ordinal because transcript blocks carry no tool ids. */ +export function pairToolBlocks( + blocks: readonly NativeChatBlock[], + limit = Infinity +): NativeChatToolPair[] { + const pairs: NativeChatToolPair[] = [] + const callSlots: (number | null)[] = [] + let resultOrdinal = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + if (pairs.length < limit) { + callSlots.push(pairs.length) + pairs.push({ call: block }) + } else { + callSlots.push(null) + } + continue + } + if (block.type !== 'tool-result') { + continue + } + const slot = callSlots[resultOrdinal] + if (slot === undefined) { + if (pairs.length < limit) { + pairs.push({ result: block }) + } + } else { + resultOrdinal += 1 + if (slot !== null) { + pairs[slot]!.result = block + } + } + } + return pairs +} + +export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { + prose: NativeChatBlock[] + tools: NativeChatBlock[] +} { + const prose: NativeChatBlock[] = [] + const tools: NativeChatBlock[] = [] + for (const block of blocks) { + if (isToolCallBlock(block) || isToolResultBlock(block)) { + tools.push(block) + } else { + prose.push(block) + } + } + return { prose, tools } +} diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..ef6b385806d --- /dev/null +++ b/src/shared/native-chat-tool-summary.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from './native-chat-types' +import { briefToolArg, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary' + +describe('summarizeToolInput bounded preview', () => { + it('collapses depth beyond the bound instead of serializing the whole tree', () => { + const deep = { a: { b: { c: { d: 'buried' } } } } + const preview = summarizeToolInput(deep) + expect(preview).toContain('[…]') + expect(preview).not.toContain('buried') + }) + + it('truncates oversized collections with an ellipsis marker', () => { + const wide = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`k${i}`, i])) + const preview = summarizeToolInput(wide) + expect(preview).toContain('…') + expect(preview).not.toContain('k11') + }) + + it('survives circular references', () => { + const cyclic: Record = { name: 'loop' } + cyclic.self = cyclic + expect(summarizeToolInput(cyclic)).toContain('[circular]') + }) +}) + +describe('briefToolArg', () => { + it('extracts the basename from forward- and backslash paths', () => { + expect(briefToolArg({ file_path: 'src/app/main.tsx' })).toBe('main.tsx') + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) + + it('falls back to the command preview when no path is present', () => { + expect(briefToolArg({ command: 'git status --short' })).toBe('git status --short') + }) +}) + +describe('summarizeToolRun', () => { + it('caps the run summary and skips nameless calls', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: ' ', input: {} }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } }, + { type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } }, + { type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } } + ] + const summary = summarizeToolRun(blocks) + expect(summary).toBe('Bash ls · Read a.ts · Edit b.ts') + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts new file mode 100644 index 00000000000..efe606f55a9 --- /dev/null +++ b/src/shared/native-chat-tool-summary.ts @@ -0,0 +1,140 @@ +import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' + +const MAX_PREVIEW_LENGTH = 80 +const MAX_PREVIEW_STRING_INPUT = 160 +const MAX_PREVIEW_COLLECTION_ITEMS = 8 +const MAX_PREVIEW_DEPTH = 2 +const MAX_TOOL_RUN_SUMMARY_PARTS = 3 + +export function summarizeToolInput(input: unknown): string { + const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() + return collapsed.length <= MAX_PREVIEW_LENGTH + ? collapsed + : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` +} + +/** Full, pretty-printed tool-call input for the expanded detail view. Strings + * pass through as-is; objects/arrays print as indented JSON so a diff-less call + * (e.g. a question payload) reads cleanly instead of one long minified line. */ +export function formatToolInput(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input === 'number' || typeof input === 'boolean') { + return String(input) + } + try { + return JSON.stringify(input, null, 2) ?? '' + } catch { + return '' + } +} + +export function toolFilePath(input: unknown): string | null { + if (!input || typeof input !== 'object') { + return null + } + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + return typeof path === 'string' && path.length > 0 ? path : null +} + +export function briefToolArg(input: unknown): string { + if (input && typeof input === 'object') { + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + if (typeof path === 'string' && path.length > 0) { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? path + } + const command = value.command ?? value.cmd ?? value.query ?? value.pattern + if (typeof command === 'string') { + return summarizeToolInput(command).slice(0, 28) + } + } + return summarizeToolInput(input).slice(0, 28) +} + +export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { + const parts: string[] = [] + for (const block of blocks) { + if (!isToolCallBlock(block)) { + continue + } + const name = block.name.trim() + if (!name) { + continue + } + const detail = briefToolArg(block.input) + parts.push(detail ? `${name} ${detail}` : name) + if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) { + break + } + } + return parts.join(' · ') +} + +export function countToolCalls(blocks: readonly NativeChatBlock[]): number { + return blocks.filter(isToolCallBlock).length +} + +function toRawPreview(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input !== 'object') { + return String(input) + } + try { + return JSON.stringify(boundedPreviewValue(input, 0, new WeakSet())) ?? '' + } catch { + return '' + } +} + +function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === 'string') { + return value.length > MAX_PREVIEW_STRING_INPUT + ? `${value.slice(0, MAX_PREVIEW_STRING_INPUT)}…` + : value + } + if (!value || typeof value !== 'object') { + return value + } + if (seen.has(value)) { + return '[circular]' + } + if (depth >= MAX_PREVIEW_DEPTH) { + return '[…]' + } + seen.add(value) + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_PREVIEW_COLLECTION_ITEMS) + .map((item) => boundedPreviewValue(item, depth + 1, seen)) + if (value.length > MAX_PREVIEW_COLLECTION_ITEMS) { + result.push('…') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MAX_PREVIEW_COLLECTION_ITEMS) { + result['…'] = '…' + break + } + result[key] = boundedPreviewValue((value as Record)[key], depth + 1, seen) + count += 1 + } + return result +} diff --git a/src/shared/quick-open-readdir-budget.ts b/src/shared/quick-open-readdir-budget.ts new file mode 100644 index 00000000000..14fd599cf64 --- /dev/null +++ b/src/shared/quick-open-readdir-budget.ts @@ -0,0 +1,38 @@ +export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 +export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 + +export type QuickOpenReaddirBudget = { + remainingFiles: number + deadlineMs: number +} + +export function createQuickOpenReaddirBudget( + opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} +): QuickOpenReaddirBudget { + return { + remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, + deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) + } +} + +const FILE_LISTING_TIMED_OUT = 'File listing timed out' +const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' + +/** Budget errors are the only fallback failures translated into install-rg guidance. */ +export function isQuickOpenReaddirBudgetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : '' + return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) +} + +export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): void { + if (Date.now() > budget.deadlineMs) { + throw new Error(FILE_LISTING_TIMED_OUT) + } +} + +export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void { + if (budget.remainingFiles <= 0) { + throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) + } + budget.remainingFiles-- +} diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 2331bc3b1c3..e6894bd815c 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -434,6 +434,17 @@ describe('quick-open readdir walk', () => { ).rejects.toThrow('File listing timed out') }) + it('returns a successful bounded prefix without consuming the safety cap', async () => { + const root = await makeTempRoot() + await writeRel(root, 'a.ts') + await writeRel(root, 'b.ts') + + const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('does not list symlinked files or follow symlinked directories', async () => { const root = await makeTempRoot() await writeRel(root, 'src/index.ts') diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index ada9442cd23..fbbb24963cc 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -8,16 +8,22 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from './quick-open-filter' +import { + assertQuickOpenReaddirDeadline, + consumeQuickOpenReaddirFileBudget, + createQuickOpenReaddirBudget, + type QuickOpenReaddirBudget +} from './quick-open-readdir-budget' + +export { + createQuickOpenReaddirBudget, + isQuickOpenReaddirBudgetError, + QUICK_OPEN_READDIR_MAX_FILES, + QUICK_OPEN_READDIR_TIMEOUT_MS +} from './quick-open-readdir-budget' -export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 -export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 const QUICK_OPEN_READDIR_CONCURRENCY = 32 -export type QuickOpenReaddirBudget = { - remainingFiles: number - deadlineMs: number -} - export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' export type QuickOpenGitLsFilesEntry = { @@ -44,42 +50,6 @@ export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFile } } -export function createQuickOpenReaddirBudget( - opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} -): QuickOpenReaddirBudget { - return { - remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, - deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) - } -} - -const FILE_LISTING_TIMED_OUT = 'File listing timed out' -const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' - -/** - * Why: the readdir walk can exhaust its cap/deadline even on the git path (a - * git monorepo parent with a huge nested repo). Callers translate only these - * budget errors into "install rg" guidance, leaving genuine git failures with - * their own messages. - */ -export function isQuickOpenReaddirBudgetError(error: unknown): boolean { - const message = error instanceof Error ? error.message : '' - return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) -} - -function assertWithinDeadline(budget: QuickOpenReaddirBudget): void { - if (Date.now() > budget.deadlineMs) { - throw new Error(FILE_LISTING_TIMED_OUT) - } -} - -function consumeFileBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingFiles <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) - } - budget.remainingFiles-- -} - function shouldDescend(name: string): boolean { return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) } @@ -162,6 +132,7 @@ export async function listQuickOpenFilesWithReaddir( excludePathPrefixes?: readonly string[] workspaceRelPathPrefix?: string budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal } = {} ): Promise { @@ -175,7 +146,8 @@ export async function listQuickOpenFilesWithReaddir( } ], opts.budget ?? createQuickOpenReaddirBudget(), - opts.signal + opts.signal, + opts.maxResults ) } @@ -191,9 +163,13 @@ type QuickOpenReaddirRoot = { async function listQuickOpenFilesFromRoots( roots: readonly QuickOpenReaddirRoot[], budget: QuickOpenReaddirBudget, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const files: string[] = [] + if (maxResults !== undefined && maxResults <= 0) { + return files + } let pendingDirectories = roots.map((root) => ({ root, absPath: root.rootPath, @@ -211,7 +187,7 @@ async function listQuickOpenFilesFromRoots( // so the shared cap remains exact while shallow placeholder-heavy repos // do not pay one relay event-loop turn per directory. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY) const entryGroups = await Promise.all( batch.map(async (pending) => { @@ -240,12 +216,12 @@ async function listQuickOpenFilesFromRoots( // Why: an empty directory has no per-entry checkpoint below. Cancellation // or timeout that lands during readdir must still reject, never resolve []. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) for (const { pending, entries } of entryGroups) { for (const entry of entries) { throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const name = entry.name const absPath = join(pending.absPath, name) @@ -266,12 +242,17 @@ async function listQuickOpenFilesFromRoots( (entry.isFile() || (pending.root.includeSymlinks && entry.isSymbolicLink())) && shouldIncludeQuickOpenPath(workspaceRelPath) ) { - consumeFileBudget(budget) + consumeQuickOpenReaddirFileBudget(budget) files.push( pending.root.outputPathPrefix ? `${pending.root.outputPathPrefix}/${relPath}` : relPath ) + // Why: a caller result limit is a successful bounded prefix, while + // the separate traversal budget still rejects incomplete scans. + if (maxResults !== undefined && files.length >= maxResults) { + return files + } } } } @@ -288,6 +269,7 @@ export async function expandQuickOpenGitFileListing(opts: { directoryPaths?: Iterable excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal }): Promise { const files = new Set() @@ -309,7 +291,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.gitPaths) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) if (kind === 'keep') { @@ -325,7 +307,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.directoryPaths ?? []) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const relPath = normalizeGitEntry(rawPath) // Why: Git intentionally leaves collapsed directories unexpanded; reject @@ -356,11 +338,12 @@ export async function expandQuickOpenGitFileListing(opts: { includeSymlinks })), budget, - opts.signal + opts.signal, + opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size) ) for (const expandedFile of expandedFiles) { addFinalPath(expandedFile) } - return Array.from(files) + return Array.from(files).slice(0, opts.maxResults) } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index b355bda7dfa..0a2dd4a2e88 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -454,32 +454,37 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) - it('tears down the socket when a request times out and reconnects active subscriptions', async () => { - const server = await createServer({ silentMethods: ['worktree.hang'] }) - const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - const onResponse = vi.fn() - const onClose = vi.fn() - - await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { - onResponse, - onError: vi.fn(), - onClose + it('keeps unrelated pending requests alive when one request times out', async () => { + const server = await createServer({ + silentMethods: ['worktree.hang'], + delayedMethods: ['worktree.ps'] }) - await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - // Why: mirrors RemoteRuntimeRequestConnection — a request the server never - // answered marks the socket as suspect and must not leave it 'ready'. - await expect(connection.request('worktree.hang', undefined, 50)).rejects.toThrow('Timed out') - - await vi.waitFor(() => expect(server.connectionCount()).toBe(2), { timeout: 5000 }) - await vi.waitFor( - () => - expect( - server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') - ).toHaveLength(2), - { timeout: 5000 } + const timedOut = connection.request('worktree.hang', undefined, 250) + void timedOut.catch(() => undefined) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.hang') ) - expect(onClose).not.toHaveBeenCalled() + const survivor = connection.request('worktree.ps', undefined, 1000).then( + (response) => ({ ok: true as const, response }), + (error: unknown) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.ps') + ) + + await expect(timedOut).rejects.toThrow('Timed out') + // Why: a single slow method is not evidence that a shared socket is dead; + // liveness monitoring owns connection-wide failure detection. + expect(connection.getDiagnostics()).toMatchObject({ state: 'ready', pendingRequestCount: 1 }) + + server.flushDelayedResponses() + await expect(survivor).resolves.toMatchObject({ + ok: true, + response: { ok: true, result: { method: 'worktree.ps' } } + }) + expect(server.connectionCount()).toBe(1) connection.close() }) @@ -515,6 +520,7 @@ async function createServer( // Why: half-open simulation — the socket stays open but never answers // protocol pings, like a wedged tunnel that swallows frames silently. disableAutoPong?: boolean + delayedMethods?: string[] silentMethods?: string[] } = {} ): Promise { @@ -613,6 +619,7 @@ function handleRequest( sendUnknownResponseBeforeResponse?: boolean closeAfterStreamingResponse?: () => boolean closeBeforeResponse?: boolean + delayedMethods?: string[] silentMethods?: string[] }, delayedResponses: (() => void)[] @@ -665,6 +672,10 @@ function handleRequest( delayedResponses.push(sendResponse) return } + if (options.delayedMethods?.includes(request.method)) { + delayedResponses.push(sendResponse) + return + } if (options.responseDelayMs !== undefined) { setTimeout(() => { sendResponse() diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 585d3478cba..88be10796b8 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -74,10 +74,7 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId, requestMethod, requestParams) => - this.sendRequest(requestId, requestMethod, requestParams), - // Why: a timed-out request marks the socket as suspect (#7718) — tear - // it down so reconnect+replay runs instead of keeping a zombie socket. - onTimeout: (error) => this.handleSocketClosed(error) + this.sendRequest(requestId, requestMethod, requestParams) }) } diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts index 6c1ec5c7db5..2f49bc082d1 100644 --- a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -22,10 +22,8 @@ describe('shared control keepalive timeout refresh semantics', () => { function startRequest(options: { refreshTimeoutOnKeepalive?: boolean } = {}): { pendingRequests: Map> promise: Promise - onTimeout: ReturnType } { const pendingRequests = new Map>() - const onTimeout = vi.fn() const promise = requestSharedControl({ pendingRequests, method: 'git.status', @@ -35,16 +33,15 @@ describe('shared control keepalive timeout refresh semantics', () => { // server never answers, modelling a genuinely stuck server-side call. ensureReady: () => Promise.resolve(), send: () => undefined, - onTimeout, refreshTimeoutOnKeepalive: options.refreshTimeoutOnKeepalive }) // Swallow the eventual rejection so unhandled-rejection noise doesn't leak. promise.catch(() => undefined) - return { pendingRequests, promise, onTimeout } + return { pendingRequests, promise } } it('times out a stuck short RPC even while keepalive frames keep arriving', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() // Periodic keepalives arrive faster than the 1000ms deadline — as they // would while a long-poll subscription streams over the same socket. @@ -55,13 +52,11 @@ describe('shared control keepalive timeout refresh semantics', () => { await vi.advanceTimersByTimeAsync(1) await expect(promise).rejects.toThrow() - // The stuck-request path tears the connection down so reconnect+replay runs. - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) it('keeps refreshing a long-poll request that opted into keepalive refresh', async () => { - const { pendingRequests, promise, onTimeout } = startRequest({ + const { pendingRequests, promise } = startRequest({ refreshTimeoutOnKeepalive: true }) @@ -72,7 +67,6 @@ describe('shared control keepalive timeout refresh semantics', () => { refreshSharedControlPendingRequestTimeouts(pendingRequests) } - expect(onTimeout).not.toHaveBeenCalled() expect(pendingRequests.size).toBe(1) // It still resolves normally once the server finally answers. @@ -87,12 +81,11 @@ describe('shared control keepalive timeout refresh semantics', () => { }) it('fires the deadline for a short RPC when no keepalives arrive', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() await vi.advanceTimersByTimeAsync(1001) await expect(promise).rejects.toThrow() - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) }) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 91f555af787..759ac4f8757 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,9 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - remoteRuntimeTimeoutError, - remoteRuntimeUnavailableError -} from './remote-runtime-request-frames' +import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' @@ -16,7 +12,6 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void - onTimeout?: (error: RemoteRuntimeClientError) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean @@ -29,16 +24,9 @@ export function requestSharedControl(args: { return } args.pendingRequests.delete(requestId) + // Why: one stalled method does not prove the shared socket is dead; + // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) - // Why: a request the server never answered means the socket is suspect - // (half-open tunnels swallow frames silently); mirror - // RemoteRuntimeRequestConnection and hand the connection a teardown - // error so reconnect+replay runs instead of keeping a zombie socket. - args.onTimeout?.( - remoteRuntimeUnavailableError( - 'Remote Orca runtime did not answer in time; resetting the control connection.' - ) - ) }, args.timeoutMs) args.pendingRequests.set(requestId, { method: args.method, diff --git a/src/shared/types.ts b/src/shared/types.ts index 03a7e7b2471..3de8e943d16 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2599,6 +2599,9 @@ export type GlobalSettings = { * system tray instead of quitting Orca; off keeps the default quit-on-close. * The tray icon itself is always present on Windows regardless of this flag. */ minimizeToTrayOnClose?: boolean + /** Why: macOS keeps Orca running after its last window closes, so this + * controls the additive menu-bar entry without changing Dock behavior. */ + showMenuBarIcon?: boolean /** Why: Windows terminals conventionally use right-click as a paste gesture, * while macOS/Linux default to their existing context menu behavior. */ terminalRightClickToPaste: boolean diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts new file mode 100644 index 00000000000..5f3bfca177b --- /dev/null +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -0,0 +1,190 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { ensureTerminalVisible } from './helpers/store' + +type PaneGeometry = { + width: number + flex: string + cols: number | null + rows: number | null + proposed: { cols: number; rows: number } | null +} + +type DividerGeometry = { + first: PaneGeometry + second: PaneGeometry +} + +test.use({ seedTestRepo: false }) + +async function setFullscreen(electronApp: ElectronApplication, page: Page): Promise { + await expect + .poll(async () => { + try { + return await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + return false + } + if (window.isMinimized()) { + window.restore() + } + window.show() + window.focus() + window.setFullScreen(true) + return window.isFullScreen() + }) + } catch { + return false + } + }) + .toBe(true) + await expect.poll(() => page.evaluate(() => innerWidth >= 1000 && innerHeight >= 700)).toBe(true) + await page.waitForTimeout(1200) +} + +async function addTestRepo(page: Page, repoPath: string): Promise { + const repoId = await page.evaluate(async (path) => { + const result = await window.api.repos.add({ path }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, repoPath) + await expect + .poll(() => + page.evaluate(async (id) => { + const store = window.__store + if (!store) { + return false + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(id) + const worktree = store + .getState() + .worktreesByRepo[id]?.find((candidate) => candidate.isMainWorktree) + if (!worktree) { + return false + } + store.getState().setActiveWorktree(worktree.id) + return true + }, repoId) + ) + .toBe(true) +} + +async function readDividerGeometry(page: Page): Promise { + return page.evaluate(() => { + const divider = document.querySelector('.pane-divider.is-vertical') + const firstElement = divider?.previousElementSibling as HTMLElement | null + const secondElement = divider?.nextElementSibling as HTMLElement | null + if (!divider || !firstElement || !secondElement) { + throw new Error('Divider unavailable') + } + const readPane = (element: HTMLElement): PaneGeometry => { + const paneElement = element.matches('.pane[data-pty-id]') + ? element + : element.querySelector('.pane[data-pty-id]') + const ptyId = paneElement?.dataset.ptyId + const pane = ptyId + ? Array.from(window.__paneManagers?.values() ?? []) + .flatMap((manager) => manager.getPanes()) + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + : null + let proposed = null + try { + proposed = pane?.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + return { + width: element.getBoundingClientRect().width, + flex: element.style.flex, + cols: pane?.terminal.cols ?? null, + rows: pane?.terminal.rows ?? null, + proposed + } + } + return { first: readPane(firstElement), second: readPane(secondElement) } + }) +} + +function gridsMatch(geometry: DividerGeometry): boolean { + return [geometry.first, geometry.second].every( + (pane) => + pane.proposed !== null && pane.cols === pane.proposed.cols && pane.rows === pane.proposed.rows + ) +} + +test('@headful keeps resizing after the divider loses pointer capture', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + await setFullscreen(electronApp, orcaPage) + await addTestRepo(orcaPage, testRepoPath) + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 30_000) + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2, 30_000) + + const divider = orcaPage.locator('.pane-divider.is-vertical').first() + await expect(divider).toBeVisible() + const box = await divider.boundingBox() + if (!box) { + throw new Error('Divider has no bounding box') + } + await divider.evaluate((element) => { + element.dataset.captureLossCount = '0' + element.addEventListener('pointerdown', (event) => { + element.dataset.captureLossPointerId = String(event.pointerId) + }) + element.addEventListener('lostpointercapture', () => { + element.dataset.captureLossCount = String(Number(element.dataset.captureLossCount ?? '0') + 1) + }) + }) + + const before = await readDividerGeometry(orcaPage) + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + await orcaPage.mouse.move(startX, startY) + await orcaPage.mouse.down() + await orcaPage.mouse.move(startX + 140, startY, { steps: 10 }) + await expect + .poll(async () => + Math.abs((await readDividerGeometry(orcaPage)).first.width - before.first.width) + ) + .toBeGreaterThan(80) + + // Why: this is the real browser event observed during field failures; later + // window-level pointer events must remain authoritative after capture drops. + await divider.evaluate((element) => { + const pointerId = Number(element.dataset.captureLossPointerId) + if (!Number.isInteger(pointerId) || !element.hasPointerCapture(pointerId)) { + throw new Error('Divider did not acquire pointer capture') + } + element.releasePointerCapture(pointerId) + }) + await expect + .poll(() => divider.evaluate((element) => Number(element.dataset.captureLossCount ?? '0'))) + .toBe(1) + + await orcaPage.mouse.move(startX + 260, startY, { steps: 10 }) + await orcaPage.mouse.up() + await expect.poll(async () => gridsMatch(await readDividerGeometry(orcaPage))).toBe(true) + const after = await readDividerGeometry(orcaPage) + await testInfo.attach('divider-capture-loss-geometry', { + body: Buffer.from(JSON.stringify({ before, after }, null, 2)), + contentType: 'application/json' + }) + + expect(Math.abs(after.first.width - before.first.width)).toBeGreaterThan(180) + expect(Math.abs(after.second.width - before.second.width)).toBeGreaterThan(180) + expect(after.first.flex).not.toBe(before.first.flex) + expect(after.second.flex).not.toBe(before.second.flex) +}) diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts index 395353cbe48..9d3b4914147 100644 --- a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -95,8 +95,8 @@ test.describe('terminal push-delivery loss recovery', () => { async () => orcaPage.evaluate( () => - (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot()?.healCount ?? - 0 + (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() + ?.healCount ?? 0 ), { timeout: 30_000 } ) diff --git a/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts new file mode 100644 index 00000000000..2d4a85ec3b7 --- /dev/null +++ b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts @@ -0,0 +1,263 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + execInTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +type PaneProbe = { + content: string + cols: number + rows: number + proposed: { cols: number; rows: number } | null + rect: { width: number; height: number } +} + +function frameNumber(content: string): number { + const matches = [...content.matchAll(/REATTACH_FRAME_(\d+)/g)] + return Number(matches.at(-1)?.[1] ?? 0) +} + +function heartbeatNumber(path: string): number { + return Number(readFileSync(path, 'utf8').trim()) +} + +function writeStreamingTui(scriptPath: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + [ + "import { writeFileSync } from 'node:fs'", + 'const heartbeatPath = process.argv[2]', + 'let frame = 0', + "process.stdout.write('\\x1b[?1049h\\x1b[?25l')", + 'setInterval(() => {', + ' frame += 1', + ' writeFileSync(heartbeatPath, String(frame))', + ' const body = [', + " '╭────────────────────────────────────────────────────╮',", + " `│ Orca warm reattach frame ${String(frame).padStart(6, '0')} 🟢 │`,", + " '├────────────────────────────────────────────────────┤',", + ' `│ REATTACH_FRAME_${frame} live daemon output │`,', + " '╰────────────────────────────────────────────────────╯'", + " ].join('\\r\\n')", + ' process.stdout.write(`\\x1b[?2026h\\x1b[2J\\x1b[H${body}\\x1b[?2026l`)', + '}, 50)', + '' + ].join('\n') + ) +} + +async function setFullscreen(app: ElectronApplication, page: Page): Promise { + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.show() + window.setFullScreen(true) + }) + await expect + .poll( + async () => ({ + fullscreen: await app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ), + size: await page.evaluate(() => ({ width: innerWidth, height: innerHeight })) + }), + { timeout: 15_000 } + ) + .toMatchObject({ + fullscreen: true, + size: { width: expect.any(Number), height: expect.any(Number) } + }) + await page.waitForTimeout(1_200) +} + +async function createActiveDecoyTab(page: Page, worktreeId: string): Promise { + await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, worktreeId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +async function findRestoredTabForPty(page: Page, ptyId: string): Promise { + return page.evaluate((ptyId) => { + const layouts = window.__store?.getState().terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(layouts)) { + if (Object.values(layout.ptyIdsByLeafId ?? {}).includes(ptyId)) { + return tabId + } + } + return null + }, ptyId) +} + +async function activateTabWithoutFocus(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTabType('terminal') + store.getState().setActiveTab(tabId) + }, tabId) +} + +async function probePane(page: Page, tabId: string, ptyId: string): Promise { + return page.evaluate( + ({ tabId, ptyId }) => { + const pane = window.__paneManagers + ?.get(tabId) + ?.getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + if (!pane) { + return null + } + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const rect = pane.container.getBoundingClientRect() + return { + content: pane.serializeAddon.serialize(), + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + rect: { width: rect.width, height: rect.height } + } + }, + { tabId, ptyId } + ) +} + +test.describe.configure({ mode: 'serial' }) + +test('restored hidden split drains live alternate-screen output without a click @headful', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both app launches. +{}, testInfo) => { + test.setTimeout(300_000) + test.skip(process.platform === 'win32', 'The streaming fixture uses a POSIX shell command') + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + + const scriptPath = testInfo.outputPath('streaming-tui.mjs') + const heartbeatPath = testInfo.outputPath('streaming-tui-heartbeat.txt') + writeStreamingTui(scriptPath) + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await setFullscreen(first.app, first.page) + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + await waitForPaneCount(first.page, 1, 30_000) + + await splitActiveTerminalPane(first.page, 'horizontal') + await waitForPaneCount(first.page, 2, 30_000) + const split = await waitForPaneIdentitySnapshot(first.page, 2) + const streamingPane = split.panes.find((pane) => pane.leafId === split.activeLeafId) + if (!streamingPane?.ptyId) { + throw new Error('Split did not expose its active PTY') + } + expect(streamingPane.ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + await execInTerminal( + first.page, + streamingPane.ptyId, + `node ${JSON.stringify(scriptPath)} ${JSON.stringify(heartbeatPath)}` + ) + await expect + .poll( + async () => + frameNumber( + (await probePane(first.page, split.tabId, streamingPane.ptyId))?.content ?? '' + ), + { + timeout: 20_000, + message: 'Streaming TUI did not start in the split pane' + } + ) + .toBeGreaterThan(5) + + // Hide the split tab before quit so relaunch restores the same cold-view boundary as the field incident. + await createActiveDecoyTab(first.page, worktreeId) + await first.page.waitForTimeout(1_000) + const beforeCloseHeartbeat = heartbeatNumber(heartbeatPath) + await session.close(firstApp) + firstApp = null + await new Promise((resolve) => setTimeout(resolve, 750)) + expect(heartbeatNumber(heartbeatPath)).toBeGreaterThan(beforeCloseHeartbeat) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await waitForActiveWorktree(second.page) + await ensureTerminalVisible(second.page) + + let restoredTabId: string | null = null + await expect + .poll( + async () => { + restoredTabId = await findRestoredTabForPty(second.page, streamingPane.ptyId!) + return restoredTabId + }, + { timeout: 20_000, message: 'Persisted split PTY was not restored into a tab layout' } + ) + .not.toBeNull() + await activateTabWithoutFocus(second.page, restoredTabId!) + await waitForActiveTerminalManager(second.page, 30_000) + await waitForPaneCount(second.page, 2, 30_000) + + const initial = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + expect(initial, 'Streaming pane did not remount after tab activation').not.toBeNull() + const initialFrame = frameNumber(initial!.content) + const initialHeartbeat = heartbeatNumber(heartbeatPath) + await second.page.waitForTimeout(2_000) + const later = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + const laterHeartbeat = heartbeatNumber(heartbeatPath) + + expect( + laterHeartbeat, + 'Fixture process stopped instead of exercising live output' + ).toBeGreaterThan(initialHeartbeat + 20) + expect(later?.rect.width ?? 0).toBeGreaterThan(0) + expect(later?.rect.height ?? 0).toBeGreaterThan(0) + expect(later?.proposed).toEqual({ cols: later?.cols, rows: later?.rows }) + expect( + frameNumber(later?.content ?? ''), + `Renderer frame did not advance while daemon heartbeat moved ${initialHeartbeat} -> ${laterHeartbeat}; initial=${JSON.stringify(initial)} later=${JSON.stringify(later)}` + ).toBeGreaterThan(initialFrame + 20) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})