diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b..34ba98f2566 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5375,7 +5375,7 @@ "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552, "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59, "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238, - "src/renderer/src/components/editor/csv-parse.test.ts": 122, + "src/renderer/src/components/editor/csv/csv-parse.test.ts": 122, "src/renderer/src/components/editor/details-markdown-html.test.ts": 99, "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35, "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48, diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 28a3155203a..c2243cd6cc3 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve()) vi.mock('react-native', () => ({ Linking: { openURL: (url: string) => openURL(url) }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 3e3d7a8b906..0022daf8d73 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,5 +1,6 @@ import { openExternalLink } from '../platform/external-link' import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher' +import { INLINE_TEXT_SELECTION } from './inline-text-selection' import { MobileSelectableText } from './MobileSelectableText' import { Fragment, @@ -33,6 +34,8 @@ type Props = { fallback?: string /** Enables iOS range selection for native-chat transcript prose. */ rangeSelectable?: boolean + /** Forward long presses from interactive Android transcript spans to the message. */ + onLongPress?: () => void /** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1; * the chat view passes >1 so agent prose reads larger than the compact base. */ textScale?: number @@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40 const MAX_TABLE_COLUMNS = 8 /** Prose base size — passed to MermaidDiagram fallback mono text. */ const MERMAID_BASE = 13 -const MarkdownTextContext = createContext>(NativeText) +type MarkdownTextSetup = { + TextComponent: ComponentType + /** Disable native selection only within the Android transcript. */ + androidTranscript: boolean + onLongPress?: () => void +} +const MarkdownTextContext = createContext({ + TextComponent: NativeText, + androidTranscript: false +}) function MarkdownText(props: TextProps): React.JSX.Element { - const TextComponent = useContext(MarkdownTextContext) - return createElement(TextComponent, props) + const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext) + if (!androidTranscript) { + return createElement(TextComponent, props) + } + // Override selection without changing the nested spans' inherited behavior. + return createElement(TextComponent, { + ...props, + ...(props.selectable === true ? { selectable: false } : {}), + ...(onLongPress && props.onPress ? { onLongPress } : {}) + }) } // Web/mail hrefs open the system handler; file-target hrefs (file: URIs and @@ -215,6 +235,9 @@ function MobileMarkdownContent({ textScale = 1, onOpenFile }: Props) { + // Interactive children own their touches and must forward the row action. + const setup = useContext(MarkdownTextContext) + const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) @@ -288,6 +311,7 @@ function MobileMarkdownContent({ key={index} style={styles.imageFrame} onPress={() => openMarkdownHref(block.url, onOpenFile)} + onLongPress={rowLongPress} > {block.alt || 'Open image'} @@ -380,9 +404,19 @@ function MobileMarkdownContent({ } function MobileMarkdownInner(props: Props): React.JSX.Element | null { - const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText + const { rangeSelectable = false, onLongPress } = props + // Other Markdown surfaces retain their existing selection behavior. + const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION + const setup = useMemo( + () => ({ + TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText, + androidTranscript, + ...(onLongPress ? { onLongPress } : {}) + }), + [rangeSelectable, androidTranscript, onLongPress] + ) return ( - + ) diff --git a/mobile/src/components/inline-text-selection.test.ts b/mobile/src/components/inline-text-selection.test.ts new file mode 100644 index 00000000000..72fcad28afe --- /dev/null +++ b/mobile/src/components/inline-text-selection.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })) + +const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } = + await import('./inline-text-selection') + +describe('inline text selection policy', () => { + it('keeps inline selection everywhere but Android', () => { + expect(inlineTextSelectionAllowed('ios')).toBe(true) + expect(inlineTextSelectionAllowed('web')).toBe(true) + expect(inlineTextSelectionAllowed('android')).toBe(false) + }) + + it('resolves the running platform once', () => { + expect(INLINE_TEXT_SELECTION).toBe(false) + }) +}) diff --git a/mobile/src/components/inline-text-selection.ts b/mobile/src/components/inline-text-selection.ts new file mode 100644 index 00000000000..9e9165709a1 --- /dev/null +++ b/mobile/src/components/inline-text-selection.ts @@ -0,0 +1,8 @@ +import { Platform } from 'react-native' + +// Android double-tap selection can mistake successive scroll flicks for word selection. +export function inlineTextSelectionAllowed(os: string): boolean { + return os !== 'android' +} + +export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS) diff --git a/mobile/src/components/mobile-markdown-android-selection.test.tsx b/mobile/src/components/mobile-markdown-android-selection.test.tsx new file mode 100644 index 00000000000..9567ae796bb --- /dev/null +++ b/mobile/src/components/mobile-markdown-android-selection.test.tsx @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there; +// this one proves the transcript carries no selectable span on Android, where a selectable +// TextView selects words while the transcript scrolls — and that other surfaces are untouched. +vi.mock('react-native', () => ({ + Linking: { openURL: () => Promise.resolve() }, + Platform: { OS: 'android' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +const CONTENT = [ + '# Heading', + 'A paragraph with **bold**, `code` and https://example.com/link.', + '> quoted', + '- item one', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '```', + 'fenced()', + '```' +].join('\n') + +describe('MobileMarkdown on Android', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(props: Parameters[0]): ReactTestInstance[] { + act(() => { + renderer = create(createElement(MobileMarkdown, props)) + }) + return renderer!.root.findAll((node) => String(node.type) === 'Text') + } + + const tappable = (nodes: ReactTestInstance[]) => + nodes.filter((node) => typeof node.props.onPress === 'function') + + it('renders the transcript with no selectable span, leaving untouched spans alone', () => { + const all = render({ content: CONTENT, rangeSelectable: true }) + expect(all.length).toBeGreaterThan(5) + expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0) + // Inline spans (bold, code, links) never asked for selection; writing `false` onto them + // would map to `userSelect: none` on the web, so the gate must leave them alone. + const links = tappable(all) + expect(links.length).toBeGreaterThan(0) + for (const node of links) { + expect(node.props.selectable).toBeUndefined() + } + }) + + it('routes a long press on a tappable span or image to the row, so neither swallows it', () => { + const onLongPress = vi.fn() + const content = `${CONTENT}\n\n![diagram](https://example.com/diagram.png)` + const all = render({ content, rangeSelectable: true, onLongPress }) + const links = tappable(all) + expect(links.length).toBeGreaterThan(0) + for (const node of links) { + expect(node.props.onLongPress).toBe(onLongPress) + } + expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0) + const images = renderer!.root.findAll( + (node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function' + ) + expect(images.length).toBeGreaterThan(0) + for (const node of images) { + expect(node.props.onLongPress).toBe(onLongPress) + } + }) + + it('keeps other surfaces (task comments, previews) selectable as before', () => { + const all = render({ content: CONTENT }) + expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0) + }) +}) diff --git a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts index c89dfa00853..833064ee3fc 100644 --- a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts +++ b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts @@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown' vi.mock('react-native', () => ({ Linking: { openURL: vi.fn() }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/mobile-markdown-selectable.test.tsx b/mobile/src/components/mobile-markdown-selectable.test.tsx index 2b42fc70715..d581a9ebb5f 100644 --- a/mobile/src/components/mobile-markdown-selectable.test.tsx +++ b/mobile/src/components/mobile-markdown-selectable.test.tsx @@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown' vi.mock('react-native', () => ({ Linking: { openURL: () => Promise.resolve() }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts new file mode 100644 index 00000000000..fff2a8d9eba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -0,0 +1,121 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all. +// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet. +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Platform: { OS: 'android' }, + Pressable: 'Pressable', + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronDown: 'ChevronDown', + Copy: 'Copy', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench', + ChevronRight: 'ChevronRight' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +const message: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }], + timestamp: null, + source: 'transcript' +} + +describe('MobileNativeChatMessage on Android', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function byType(type: string): ReactTestInstance[] { + return renderer!.root.findAll((node) => String(node.type) === type) + } + + it('opens the actions sheet from a long press on the bubble, and closes it again', () => { + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message })) + }) + expect(byType('MessageActionsSheet')).toHaveLength(0) + + const [bubble] = byType('Pressable') + expect(typeof bubble!.props.onLongPress).toBe('function') + // The markdown gets the same handler, so a link span or image under the finger opens the sheet. + const [markdown] = byType('MobileMarkdown') + expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress) + expect(markdown!.props.rangeSelectable).toBe(true) + + act(() => bubble!.props.onLongPress()) + const [sheet] = byType('MessageActionsSheet') + expect(sheet!.props.message).toBe(message) + + act(() => sheet!.props.onClose()) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) + + it('disables inline selection for host notices too', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: { + ...message, + role: 'system', + blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }] + } + }) + ) + }) + const texts = byType('Text').filter((node) => node.props.selectable !== undefined) + expect(texts).toHaveLength(1) + expect(texts[0]!.props.selectable).toBe(false) + act(() => byType('Pressable')[0]!.props.onLongPress()) + expect(byType('MessageActionsSheet')).toHaveLength(1) + }) + + it('renders the user bubble without inline selection', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } }) + ) + }) + const texts = byType('Text').filter((node) => node.props.selectable !== undefined) + expect(texts.length).toBeGreaterThan(0) + for (const node of texts) { + expect(node.props.selectable).toBe(false) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index c8193641fef..135469d8917 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -22,6 +22,7 @@ vi.mock('react-native', async () => { timing: () => ({ start: vi.fn(), stop: vi.fn() }) }, Image: 'Image', + Platform: { OS: 'ios' }, Pressable: 'Pressable', Text, View: ({ children, ...props }: { children?: unknown }) => @@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) import { MobileNativeChatMessage } from './MobileNativeChatMessage' diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 40635166281..83f93266d6f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,6 +1,8 @@ import { MobileSelectableText as Text } from '../components/MobileSelectableText' -import { memo } from 'react' -import { Image, Text as NativeText, View } from 'react-native' +import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react' +import { Image, Text as NativeText, Pressable, View } from 'react-native' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' @@ -20,17 +22,23 @@ function Prose({ block, invert, fontScale, - onOpenFile + onOpenFile, + onLongPress }: { block: NativeChatBlock invert?: boolean fontScale: number onOpenFile?: (relativePath: string) => void + /** Android only: routes a long press on a link span to the row's actions sheet. */ + onLongPress?: () => void }): React.JSX.Element | null { if (isTextBlock(block)) { if (isAgentSessionHostStatusPresentation(block.presentation)) { return ( - + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} ) @@ -39,7 +47,10 @@ function Prose({ // markdown renderer's light-on-dark palette. if (invert) { return ( - + {block.text} ) @@ -50,6 +61,7 @@ function Prose({ rangeSelectable textScale={1.25 * fontScale} onOpenFile={onOpenFile} + onLongPress={onLongPress} /> ) } @@ -76,6 +88,25 @@ function Prose({ return null } +// Keep the existing responder hierarchy on platforms with inline selection. +function Content({ + onLongPress, + style, + children +}: { + onLongPress?: () => void + style: ComponentProps['style'] + children: ReactNode +}): React.JSX.Element { + return onLongPress ? ( + + {children} + + ) : ( + {children} + ) +} + function MobileNativeChatMessageImpl({ message, toolsExpanded = false, @@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({ !turnExpanded && !toolsExpanded const showToolRun = tools.length > 0 && !settledToolsHidden + // Mount selection UI only for the message being copied. + const [actionsOpen, setActionsOpen] = useState(false) + // Keep the memoized Markdown context stable as the message streams. + const openActions = useCallback(() => setActionsOpen(true), []) + const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions const statusRow = turnStatus ? ( - {prose.map((block, index) => ( @@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({ invert={isUser} fontScale={fontScale} onOpenFile={onOpenFile} + onLongPress={onLongPress} /> ))} {showToolRun ? ( @@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({ onOpenFile={onOpenFile} /> ) : null} - + + {actionsOpen ? ( + setActionsOpen(false)} + /> + ) : null} {turnStatusAbove ? null : statusRow} ) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx new file mode 100644 index 00000000000..34d4089af9b --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx @@ -0,0 +1,126 @@ +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 { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' + +const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() })) +vi.mock('react-native', () => ({ + Alert: { alert }, + ActivityIndicator: 'ActivityIndicator', + Modal: 'Modal', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + Text: 'Text', + View: 'View', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 }) +})) +vi.mock('lucide-react-native', () => ({ + Copy: 'Copy', + TextSelect: 'TextSelect', + X: 'X', + Edit3: 'Edit3', + Trash2: 'Trash2' +})) +vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) })) +// Keep the real drawer lifecycle; only native animation completion is driven by the test. +vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' })) + +const message: NativeChatMessage = { + id: 'reply', + source: 'transcript', + role: 'assistant', + timestamp: null, + blocks: [{ type: 'text', text: 'First words' }] +} + +describe('message selection actions', () => { + let renderer: ReactTestRenderer | null = null + const onClose = vi.fn() + const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type) + const render = (value = message) => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose }) + ) + }) + } + const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress()) + const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden()) + + beforeEach(() => { + vi.clearAllMocks() + writeText.mockResolvedValue(undefined) + }) + afterEach(() => act(() => renderer?.unmount())) + + it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => { + render() + act(() => + renderer!.update( + createElement(MobileNativeChatMessageActionsSheet, { + message: { + ...message, + blocks: [{ type: 'text', text: 'First words plus streamed text' }] + }, + onClose + }) + ) + ) + pressAction(1) + expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false) + expect(nodes('Modal')).toHaveLength(0) + expect(onClose).not.toHaveBeenCalled() + finishClosing() + expect(nodes('MountedDrawer')).toHaveLength(0) + expect(nodes('Modal')).toHaveLength(1) + const selected = nodes('Text').find((node) => node.props.selectable) + expect(selected?.props.children).toBe('First words') + act(() => nodes('Modal')[0]!.props.onRequestClose()) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes selection with the accessible close button', () => { + render() + pressAction(1) + finishClosing() + const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close') + expect(close?.props.accessibilityRole).toBe('button') + act(() => close!.props.onPress()) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('copies prose and dismisses only after the drawer closes', () => { + render() + pressAction(0) + expect(writeText).toHaveBeenCalledWith('First words') + expect(onClose).not.toHaveBeenCalled() + finishClosing() + expect(onClose).toHaveBeenCalledOnce() + expect(nodes('Modal')).toHaveLength(0) + }) + + it('surfaces clipboard rejection', async () => { + writeText.mockRejectedValue(new Error('Clipboard unavailable')) + render() + await act(async () => pressAction(0)) + expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable') + }) + + it('dismisses without selecting when the drawer is cancelled', () => { + render() + act(() => nodes('MountedDrawer')[0]!.props.onClose()) + finishClosing() + expect(onClose).toHaveBeenCalledOnce() + expect(nodes('Modal')).toHaveLength(0) + }) + + it('disables actions when the message has no copyable prose', () => { + render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] }) + expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true]) + expect(writeText).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx new file mode 100644 index 00000000000..4d9cf71c6ba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx @@ -0,0 +1,119 @@ +import { useRef, useState } from 'react' +import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Copy, TextSelect, X } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { ActionSheetContent } from '../components/ActionSheetModal' +import { BottomDrawer } from '../components/BottomDrawer' +import { useClipboardWriter } from '../platform/clipboard' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' + +type Props = { + /** The long-pressed message. The owner mounts this only while the sheet is open. */ + message: NativeChatMessage + onClose: () => void +} + +export function MobileNativeChatMessageActionsSheet({ + message, + onClose +}: Props): React.JSX.Element { + const clipboard = useClipboardWriter() + const [sheetVisible, setSheetVisible] = useState(true) + const [selecting, setSelecting] = useState(false) + // Wait for the drawer to unmount before presenting another native modal. + const selectRequested = useRef(false) + // Streaming updates must not reset an active native text selection. + const [text] = useState(() => nativeChatMessagePlainText(message)) + const closeSheet = () => setSheetVisible(false) + + return ( + <> + (selectRequested.current ? setSelecting(true) : onClose())} + dragContentToDismiss + > + { + void clipboard.writeText(text).catch((error: unknown) => { + Alert.alert( + 'Copy failed', + error instanceof Error ? error.message : 'The clipboard rejected the text.' + ) + }) + } + }, + { + label: 'Select text', + icon: TextSelect, + disabled: text.length === 0, + onPress: () => { + selectRequested.current = true + } + } + ]} + /> + + {selecting ? : null} + + ) +} + +function SelectTextScreen({ + text, + onClose +}: { + text: string + onClose: () => void +}): React.JSX.Element { + const insets = useSafeAreaInsets() + return ( + + + + Select text + + + + + + + {text} + + + + + ) +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.bgBase }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' }, + close: { padding: spacing.xs }, + body: { padding: spacing.lg }, + text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 } +}) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b2..a0393996cec 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + // The crash's notice row explains the cut; the turn bar reads like any finished turn. + it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Failed after 12s']) + expect(labels(crashed.root)).toEqual(['Worked for 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.test.ts b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts new file mode 100644 index 00000000000..b115cab8268 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' + +describe('nativeChatMessagePlainText', () => { + it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: ' indented first line\n' }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'text', text: ' \n' }, + { type: 'text', text: 'Second, with `code`.' } + ] + expect(nativeChatMessagePlainText({ blocks })).toBe( + ' indented first line\n\n\nSecond, with `code`.' + ) + }) + + it('copies the displayed host notice instead of its fallback wire text', () => { + expect( + nativeChatMessagePlainText({ + blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }] + }) + ).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + }) + + it('is empty for a message with no prose', () => { + expect( + nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] }) + ).toBe('') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.ts b/mobile/src/session/mobile-native-chat-message-plain-text.ts new file mode 100644 index 00000000000..40ca75dfaf6 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.ts @@ -0,0 +1,20 @@ +import { + AGENT_SESSION_HOST_STATUS_COPY, + isAgentSessionHostStatusPresentation +} from '../../../src/shared/agent-session-host-status-rows' +import { isTextBlock } from '../../../src/shared/native-chat-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** Copy the displayed prose, excluding tool activity and image attachments. */ +export function nativeChatMessagePlainText(message: Pick): string { + // Preserve indentation in code and nested lists. + return message.blocks + .filter(isTextBlock) + .map((block) => + isAgentSessionHostStatusPresentation(block.presentation) + ? AGENT_SESSION_HOST_STATUS_COPY[block.presentation] + : block.text + ) + .filter((text) => text.trim().length > 0) + .join('\n\n') +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 00000000000..37a29458c07 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,132 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 3 }, + nextCursor: { epoch: 'epoch-1', sequence: 4 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 3 }, + hasOlder: false, + hasNewer: false + } + } +} + +describe('useMobileStructuredAgentSession transcript', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + let listener: ((value: unknown) => void) | null = null + const client: Pick = { + sendRequest: vi.fn(async (): Promise => ({ + id: 'response-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) + } + const rendered: { hook: ReturnType | null } = { + hook: null + } + function Harness(): null { + rendered.hook = useMobileStructuredAgentSession({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + client: client as RpcClient, + sessionId: 'session-1', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: vi.fn() + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + + act(() => listener?.(snapshot(ITEMS))) + + await vi.waitFor(() => + expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + ) + // The turn bars place rows by the same items, so the notice joins the cut turn. + expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ae688ab9072..b0ecfe9832c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, @@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no + // row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, state.items] + ) const messages = useMemo( // Off: the phone hands a rejected message back to its composer, so a row would show it twice. () => - projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { rejectedInPlace: false }), - [state.items, state.submissions] + [transcriptItems, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) - const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) + const turnTiming = useMobileStructuredAgentTurnTiming( + { ...state, items: transcriptItems }, + turnId + ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index 29112c38d03..a072383a660 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -86,6 +86,32 @@ export async function prepareLegacySharedCodexSessionResume( return { useRealCodexHome: true } } +/** Explicit account restarts must move the verified rollout before changing credentials. */ +export async function prepareCodexAccountRestartResume(args: { + sourceHome: string + transcriptPath: string + targetHome: string + systemCodexHomePath: string +}): Promise { + if (sameRuntimePath(args.sourceHome, args.targetHome)) { + return args.targetHome + } + const relativePath = relative( + resolve(join(args.sourceHome, 'sessions')), + resolve(args.transcriptPath) + ) + if (!isDatedRolloutRelativePath(relativePath)) { + throw new Error(RETRYABLE_RESUME_ERROR) + } + const paths = resolveCodexSessionBackfillPaths(args.systemCodexHomePath) + await materializeLegacyRollout( + args.transcriptPath, + join(args.targetHome, 'sessions', relativePath), + paths.auditLogPath + ) + return args.targetHome +} + /** * Repins a per-account resume to the selected account's home, or null to keep * the session's own home. diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 50558a0f870..5fc47deac66 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness' -import { SessionNotFoundError } from '../daemon/daemon-errors' +import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry' +import { toAppSshPtyId } from '../providers/ssh-pty-id' +import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' @@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212' const paneKey = makePaneKey(tabId, leafId) type RestartHarness = ReturnType +type FakeSession = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: { type: 'leaf'; leafId: string } + activeLeafId: string + expandedLeafId: null + ptyIdsByLeafId: Record + } + > + terminalPtyIncarnationsByPaneKey: Record +} + +function seedSession(ptyId: string): FakeSession { + return { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } + } +} function registerWithFakes( mainWindow: PtyIpcSuiteFixtures['mainWindow'], @@ -78,13 +109,15 @@ function installRestartHarness( options: { shutdownFails?: boolean; shutdownGate?: Promise } = {} ) { let oldSessionAlive = true - const control = { shutdownFails: options.shutdownFails ?? false } + const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false } + const boundAtFreshLaunch: (string | undefined)[] = [] const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { if (!spawnOptions.attachOnly) { + boundAtFreshLaunch.push(leafBinding()) return { id: 'pty-new', incarnationId: 'inc-new' } } if (!oldSessionAlive) { - throw new SessionNotFoundError('pty-old') + throw new TerminalKilledError('pty-old') } return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } }) @@ -120,31 +153,75 @@ function installRestartHarness( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. setLocalPtyProvider(provider as unknown as Parameters[0]) - let session = { - tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: 'pty-old' } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } - } + let session = seedSession('pty-old') const store = { - getWorkspaceSession: vi.fn(() => session), + getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), setWorkspaceSession: vi.fn((next) => { session = next }), flushOrThrow: vi.fn(), - runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), - persistPtyBinding: vi.fn(), + runDurableMutation: vi.fn( + async ( + mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void } + ) => { + const mutation = mutate() + if (mutation.persist !== false) { + if (control.failNextWrite) { + control.failNextWrite = false + mutation.rollback?.() + throw new Error('save failed') + } + durableBindings.push(leafBinding()) + } + return mutation.value + } + ), + getWorkspaceSessionHostIds: vi.fn(() => [ + 'local', + ...Object.keys(state.workspaceSessionsByHostId) + ]), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) } + const state: { + workspaceSession: FakeSession + workspaceSessionsByHostId: Record + } = { workspaceSession: session, workspaceSessionsByHostId: {} } + const durableBindings: (string | undefined)[] = [] + function leafBinding(hostId?: string): string | undefined { + return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + } + // Like the store, a missing host partition reads as an empty session, never the local one. + store.getWorkspaceSession.mockImplementation((hostId?: string) => + hostId && hostId !== 'local' + ? (state.workspaceSessionsByHostId[hostId] ?? { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + terminalPtyIncarnationsByPaneKey: {} + }) + : state.workspaceSession + ) + const bindingRuntime = { + state, + dirtyProfileStateDomains: new Set(), + runDurableMutation: store.runDurableMutation, + writeTimer: null, + pendingWrite: null, + quitFlushStarted: false, + writeGeneration: 0, + lastDurableWriteGeneration: 0 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< + typeof PtyBindingPersistenceOperations + > + const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) + const storeWithRetirement = Object.assign(store, { + persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)), + retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations) + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -165,7 +242,18 @@ function installRestartHarness( onPtyData: vi.fn(), intentionalPtyStops: new TerminalIntentionalStops() } - return { providerSpawn, shutdown, store, runtime, control } + return { + providerSpawn, + shutdown, + store: storeWithRetirement, + runtime, + control, + provider, + leafBinding, + durableBindings, + boundAtFreshLaunch, + partitions: state.workspaceSessionsByHostId + } } function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) { @@ -283,4 +371,270 @@ describe('pty:spawn replacing a pane owner', () => { expect(exitPayloads('pty-old')).toHaveLength(1) expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart') }) + it('swaps the stopped binding for the replacement in one write, never unbinding the pane', async () => { + const { providerSpawn, store, runtime, durableBindings, boundAtFreshLaunch } = + installRestartHarness() + const session = store.getWorkspaceSession() + const layout = structuredClone(session.terminalLayoutsByTabId[tabId]) + registerWithFakes(mainWindow, runtime, store) + await handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + const swapped = store.getWorkspaceSession() + expect(boundAtFreshLaunch).toEqual(['pty-old']) + expect(durableBindings).toEqual(['pty-new']) + expect(swapped.tabsByWorktree[worktreeId]).toEqual([ + { ...session.tabsByWorktree[worktreeId][0], ptyId: 'pty-new' } + ]) + expect(swapped.terminalLayoutsByTabId[tabId]).toEqual({ + ...layout, + ptyIdsByLeafId: { [leafId]: 'pty-new' } + }) + expect(swapped.terminalPtyIncarnationsByPaneKey).toEqual({ [paneKey]: 'inc-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('launches fresh when a stale snapshot re-publishes the stopped binding mid-spawn', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A debounced renderer layout patch still carrying the old id lands after the stop. + const current = store.getWorkspaceSession() + current.terminalLayoutsByTabId[tabId] = { + ...current.terminalLayoutsByTabId[tabId], + ptyIdsByLeafId: { [leafId]: 'pty-old' } + } + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalled() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('swaps a binding the renderer withdrew before connecting the replacement', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A split tab's partial layout map, or an SSH terminated lease, lets the renderer clear land. + delete store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('clears the stopped binding when the replacement fails, so the remount starts fresh', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('spawn failed') + const boundAfterFailure = leafBinding() + // The renderer's recovery remount sends no replacesPtyId. + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('reaps the replacement when its binding save fails, and the remount starts fresh', async () => { + const { provider, providerSpawn, store, runtime, control, leafBinding } = + installRestartHarness() + control.failNextWrite = true + vi.spyOn(console, 'error').mockImplementation(() => {}) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + const boundAfterFailure = leafBinding() + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('refuses an old restart after another owner has already claimed the pane', async () => { + const { shutdown, providerSpawn, store, runtime } = installRestartHarness() + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(shutdown).not.toHaveBeenCalled() + expect(providerSpawn).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('reaps the replacement when a successor binds the pane during its spawn', async () => { + const { provider, providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockImplementationOnce(async () => { + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + return { id: 'pty-new', incarnationId: 'inc-new' } + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + expect(leafBinding()).toBe('successor') + }) + + /** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */ + async function withSshRestart( + run: ( + host: RestartHarness & { oldId: string; newId: string; hostId: string }, + restart: (extra?: { replacesPtyId?: string }) => Promise + ) => Promise + ): Promise { + const harness = installRestartHarness() + const connectionId = 'restart-ssh' + const hostId = 'ssh:restart-ssh' + const oldId = toAppSshPtyId(connectionId, 'pty-old') + const newId = toAppSshPtyId(connectionId, 'pty-new') + harness.partitions[hostId] = seedSession(oldId) + Object.assign(harness.store, { + markSshRemotePtyLease: vi.fn(), + upsertSshRemotePtyLease: vi.fn(), + removeSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn() + }) + let oldAlive = true + harness.shutdown.mockImplementation(async () => { + oldAlive = false + }) + harness.providerSpawn.mockImplementation(async (options) => { + if (!options.attachOnly) { + return { id: newId, incarnationId: 'inc-new' } + } + if (!oldAlive) { + throw new TerminalKilledError(oldId) + } + return { id: oldId, incarnationId: 'inc-old', isReattach: true } + }) + registerSshPtyProvider( + connectionId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture. + harness.provider as unknown as Parameters[1] + ) + const localSpawn = vi.fn(() => { + throw new Error('wrong execution host') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider. + setLocalPtyProvider({ + ...harness.provider, + spawn: localSpawn, + shutdown: localSpawn + } as unknown as Parameters[0]) + registerWithFakes(mainWindow, harness.runtime, harness.store) + try { + await run({ ...harness, oldId, newId, hostId }, async (extra = {}) => + handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId }) + ) + expect(localSpawn).not.toHaveBeenCalled() + expect(harness.leafBinding()).toBe('pty-old') + } finally { + unregisterSshPtyProvider(connectionId) + } + } + + it('swaps the direct-SSH host binding without touching the local provider', async () => { + await withSshRestart(async (host, restart) => { + await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({ + id: host.newId + }) + expect(host.shutdown).toHaveBeenCalledWith( + host.oldId, + expect.objectContaining({ immediate: true }) + ) + expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId) + expect(host.leafBinding(host.hostId)).toBe(host.newId) + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockImplementationOnce(async () => { + host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = + 'successor' + return { id: host.newId, incarnationId: 'inc-new' } + }) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(host.shutdown).toHaveBeenCalledWith( + host.newId, + expect.objectContaining({ immediate: true }) + ) + expect(host.leafBinding(host.hostId)).toBe('successor') + }) + }) + + it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed') + const boundAfterFailure = host.leafBinding(host.hostId) + await expect(restart()).resolves.toMatchObject({ id: host.newId }) + expect(boundAfterFailure).toBeUndefined() + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it.each(['darwin', 'linux', 'win32'])( + 'restarts a folder workspace on %s without deleting its pane', + async (platform) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + onTestFinished(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + const { store, runtime, leafBinding } = installRestartHarness() + const folderId = 'folder:restart-folder' + const session = store.getWorkspaceSession() + session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({ + ...tab, + worktreeId: folderId + })) + session.tabsByWorktree[worktreeId] = [] + const folder = { + id: 'restart-folder', + folderPath: process.cwd(), + name: 'Restart', + projectGroupId: null + } + Object.assign(store, { + getFolderWorkspaces: vi.fn(() => [folder]), + getFolderWorkspace: vi.fn(() => folder) + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, { + ...restartSpawnArgs({ replacesPtyId: 'pty-old' }), + worktreeId: folderId, + cwd: process.cwd() + }) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1) + expect(leafBinding()).toBe('pty-new') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({ + type: 'leaf', + leafId + }) + } + ) }) diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index a7118cb08b8..463660e00d7 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -30,6 +30,7 @@ export type PrepareCodexResumeHomeArgs = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean } export function prepareCodexResumeHome( @@ -48,7 +49,8 @@ export function prepareCodexResumeHome( preparation: prepareCodexSessionResume({ providerSession, target: args.target, - launchEnv: args.launchEnv + launchEnv: args.launchEnv, + ...(args.useSelectedAccount ? { useSelectedAccount: true } : {}) }) } } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 5e42310a45b..c3dfe1ae6e9 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index bc0e86ccd2d..a7ae10bc036 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -13,11 +13,12 @@ import { pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' +import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' /** The pane key a spawn names before preflight; null when it names no stable pane. */ -function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { +export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { const leafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null return typeof args.worktreeId === 'string' && @@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn( const initialStablePanePtyId = (() => { try { return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner )?.ptyId : undefined } catch { @@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn( } ctx.earlyStablePaneOwner = initialPaneKey && args.worktreeId - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner ) : null ctx.earlyWorktreeId = args.worktreeId diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 46fd8e714bb..41752886ab9 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, @@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) } ctx.spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + // Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach. + const resolveOwner = () => + excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner + ) + const stablePaneOwnerCandidate = resolveOwner() const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId if (expectedPtyId) { @@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { owner: stablePaneOwnerCandidate, worktreeId: args.worktreeId, connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + resolveOwner }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index d6abc77edce..83e8a77841c 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,6 +1,11 @@ import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation' import { ptySizes } from '../delivery/visibility-state' -import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin' +import { + beginPtyIpcSpawn, + resolveEarlyPaneKey, + resolveEarlyPaneSpawnReservationKey +} from './spawn-begin' +import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' import { preparePtyIpcQoderCommand } from './spawn-qoder-command' @@ -43,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } try { if (args.replacesPtyId !== undefined) { - // Why: stop before resolving the pane owner, so the spawn below finds a dead owner and - // launches fresh instead of reattaching the process this restart exists to replace. - await deps.stopReplacedPty(args.replacesPtyId) + ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, { + replacesPtyId: args.replacesPtyId, + paneKey: resolveEarlyPaneKey(args), + worktreeId: args.worktreeId, + connectionId: args.connectionId + }) } triggerPtySpawnPushTargetMaterialization(deps, args) const early = await beginPtyIpcSpawn(ctx) @@ -81,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg ) ctx.pendingRegistrationPtyId = null } + if (ctx.replacedPaneOwner) { + await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner) + } // Why: once the reservation is created, any later throw — // spawn failure, persist failure, or a post-spawn helper such as // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index b1240c4ec6e..d753ac4a6fd 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -8,6 +8,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing' import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' @@ -25,6 +26,7 @@ export type PtyIpcSpawnState = { earlyWorktreeId: string | undefined paneSpawnReservationKey: string | null paneSpawnReservation: PaneSpawnReservation | null + replacedPaneOwner: ReplacedPaneOwner | null finishTerminalInstall: () => void result: PtySpawnResult stablePaneOwner: StablePaneOwner | null @@ -103,6 +105,7 @@ export function createPtyIpcSpawnState( earlyWorktreeId: undefined, paneSpawnReservationKey: null, paneSpawnReservation: null, + replacedPaneOwner: null, finishTerminalInstall: () => {}, result: { id: '' }, stablePaneOwner: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index d1f340be17d..8e91799c429 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.test.ts b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts new file mode 100644 index 00000000000..681a48ba616 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' +import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement' + +vi.mock('../../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const worktreeId = 'repo-local::/fixture/local' +const tabId = 'restart-tab' + +function replaced(ptyId: string, hostId?: string): ReplacedPaneOwner { + return { ptyId, pane: { worktreeId, tabId, leafId: TEST_LEAF_1, hostId } } +} + +type FixtureStore = Awaited>['store'] + +/** The binding swap must survive the renderer's own pre-connect clear, which strict fences refuse. */ +async function expectSwapAfterRendererClear( + store: FixtureStore, + oldId: string, + newId: string, + hostId?: string +): Promise { + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBeUndefined() + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: newId, incarnationId: 'inc-new' } + expect( + await store.persistPtyBinding( + { ...binding, expectedBinding: { ptyId: oldId, incarnationId: 'inc-old' } }, + hostId + ) + ).toBe(false) + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced(oldId, hostId), hostId), + hostId + ) + ).toBe(true) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe(newId) +} + +it('swaps a local split pane whose renderer layout patch already dropped the stopped binding', async () => { + const { store } = await fixture() + const pane = { worktreeId, tabId } + await store.persistPtyBinding({ + ...pane, + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'inc-old' + }) + await store.persistPtyBinding({ ...pane, leafId: TEST_LEAF_2, ptyId: 'sibling' }) + const layouts = structuredClone(store.getWorkspaceSession().terminalLayoutsByTabId) + // The mounted restart clears only its own leaf before connecting; a partial map is honored. + delete layouts[tabId].ptyIdsByLeafId![TEST_LEAF_1] + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }) + await expectSwapAfterRendererClear(store, 'old', 'new') + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_2] + ).toBe('sibling') +}) + +it('swaps an SSH pane whose terminated lease let the renderer clear withdraw the binding', async () => { + const { store } = await fixture() + const hostId = 'ssh:restart' + const oldId = 'ssh:restart@@remote-old' + await store.persistPtyBinding( + { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: oldId, incarnationId: 'inc-old' }, + hostId + ) + store.upsertSshRemotePtyLease({ + targetId: 'restart', + ptyId: 'remote-old', + worktreeId, + tabId, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // What the replacement stop records (finishPtyShutdown). + store.markSshRemotePtyLease('restart', 'remote-old', 'terminated') + const layouts = structuredClone(store.getWorkspaceSession(hostId).terminalLayoutsByTabId) + layouts[tabId].ptyIdsByLeafId = {} + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }, hostId) + await expectSwapAfterRendererClear(store, oldId, 'ssh:restart@@remote-new', hostId) +}) + +it('refuses the swap when another owner holds the leaf', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'successor' }) + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'new' } + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced('old'), undefined) + ) + ).toBe(false) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe('successor') +}) diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.ts b/src/main/ipc/pty/pane/pane-owner-replacement.ts new file mode 100644 index 00000000000..0130ae8b702 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.ts @@ -0,0 +1,96 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner' + +/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */ +export type ReplacedPaneOwner = { + ptyId: string + pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null +} + +/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */ +export async function stopReplacedPaneOwner( + deps: { + runtime?: OrcaRuntimeService + store?: Store + stopReplacedPty: (id: string) => Promise + }, + args: { + replacesPtyId: string + paneKey: string | null + worktreeId: string | undefined + connectionId: string | null | undefined + } +): Promise { + const owner = resolveStablePaneOwner( + deps.runtime, + deps.store, + args.paneKey, + args.worktreeId, + args.connectionId + ) + if (owner && owner.ptyId !== args.replacesPtyId) { + throw new Error('terminal_pane_owner_changed') + } + await deps.stopReplacedPty(args.replacesPtyId) + const pane = args.paneKey ? parsePaneKey(args.paneKey) : null + return { + ptyId: args.replacesPtyId, + pane: + pane && args.worktreeId + ? { + worktreeId: args.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + hostId: args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + } + : null + } +} + +/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */ +export function excludeReplacedPaneOwner( + owner: StablePaneOwner | null, + replaced: ReplacedPaneOwner | null +): StablePaneOwner | null { + return owner && owner.ptyId === replaced?.ptyId ? null : owner +} + +/** + * The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf + * still naming the stopped id, or one the renderer already cleared; any other owner wins. + */ +export function swapReplacedPaneBinding( + store: Store, + binding: PersistPtyBindingArgs, + replaced: ReplacedPaneOwner, + hostId: string | undefined +): () => PersistPtyBindingArgs | null { + return () => { + const bound = + store.getWorkspaceSession(hostId).terminalLayoutsByTabId?.[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + // Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab, SSH lease ended). + return bound === undefined || bound === replaced.ptyId ? binding : null + } +} + +/** Best effort: a failed replacement must not leave the stopped id bound for the remount to reattach. */ +export async function releaseStoppedPaneBinding( + store: Store | undefined, + replaced: ReplacedPaneOwner +): Promise { + if (!store || !replaced.pane) { + return + } + const { hostId, ...pane } = replaced.pane + try { + await store.retirePtyBinding({ ...pane, ptyId: replaced.ptyId }, hostId) + } catch (error) { + console.warn('[pty] could not clear the stopped pane binding after a failed restart:', error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 33ef8080c03..b1922b913ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -12,6 +12,7 @@ import { import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -121,9 +122,19 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - return { turn, settled } + const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key + // Every error row a reader's transcript shows, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + return { turn, settled, label, notices } } +/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ +const ONE_NOTICE = [ + 'Codex stopped while this response was in progress. You can continue in this conversation.' +] + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter' }) + expect(label).toBe('interruptedAfter') + // The person's own close explains itself. + expect(notices).toEqual([]) }) it('leaves a close the user did not aim at this chat as news', async () => { @@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + // News for the sidebar; the turn reads like a finished one beside its one notice. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + const { label, notices } = await settledTurn() + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { @@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => { await host.collaboratorsForTests().lifetime.idleSweep.tick() expect(closeCalls).toBe(1) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) + expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -309,9 +320,10 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it('leaves a crash the provider saw before the user closed the chat as news', async () => { @@ -319,9 +331,10 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it.each(['user-close', 'evict'] as const)( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e237615089..d31a79e5f54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-adapter' import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -216,6 +217,34 @@ describe('a turn a crash cut short mid-tool', () => { expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) }) + // The turn bar reads like a finished turn, so this row is the one place the chat says why. + it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + const { items } = await host.journalSnapshot(SESSION) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( + (item) => (item.body.kind === 'status' ? [item.body] : []) + ) + expect(statusRows).toEqual([ + expect.objectContaining({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + failure: expect.objectContaining({ kind: 'providerExited' }), + tone: 'error' + }) + ]) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'workedFor', duration: '27s' }) + }) + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. let alive = true @@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed, then the folded "Failed after 27s". + // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'failedAfter', duration: '27s' }) + ).toEqual({ key: 'workedFor', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba51..f0372c8e58d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,6 +4,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // Named by the death it explains, so a retry after a partly written settle adds no second row. identity: { provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e..0857e3565d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and - // must never show the done tick of a finished turn. + // The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a + // finished turn, since the chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Failed after N, marked failed, for a turn cut off by %s', + 'reads Worked for N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -221,7 +221,7 @@ describe('a turn recovery settled after its host went away', () => { ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Failed after 1s' + 'Worked for 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 27c09bc2bbe..c748748ccd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -22,6 +22,7 @@ import { type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -120,7 +121,10 @@ function restartNoteWriter( return } await session.journal.appendItem( - { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { + provider: 'orca', + clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}` + }, { kind: 'status', text, ...(tone ? { tone } : {}) }, // About the conversation, not any turn in it. { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index b1cbc83a48e..4602630960a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -1,12 +1,15 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { interruptedRestart, + QUIT_CUT_NOTICE, + readerNotes, statusNotes, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' @@ -43,6 +46,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) expect(await statusNotes(host)).toEqual([]) + // No note took over, so the cut turn keeps its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the @@ -51,6 +56,8 @@ it('says so in the chat when the agent cannot start for the continuation', async const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + // Before the action, the quit's cut reads with its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -61,6 +68,24 @@ it('says so in the chat when the agent cannot start for the continuation', async text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) + // The refusal note is now the one explanation: the cut is not said a second time beside it. + expect(await readerNotes(host)).toEqual(await statusNotes(host)) +}) + +// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in +// for the cut: its note is written after its own message, so the cut keeps its notice throughout. +it("keeps the quit's notice through a continuation that carries the chat on", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined } + ]) }) // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index fe97834a189..59d478cbb40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,4 +1,8 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -177,11 +181,30 @@ export function throwAfterContinuationAccepted(): void { } export async function statusNotes(host: StructuredAgentSessionHost) { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + return notesOf((await host.journalSnapshot(SESSION)).items) +} + +/** The notes a reader's transcript shows, the cut turn's derived notice included. */ +export async function readerNotes(host: StructuredAgentSessionHost) { + return notesOf( + withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, { + agentName: 'Codex' + }) + ) +} + +function notesOf(items: readonly AgentJournalRenderItem[]) { + return items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] ) } +/** The one row the quit's cut turn reads with when nothing else explains it. */ +export const QUIT_CUT_NOTICE = { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' +} + /** A continuation the host refuses because the user's own message was accepted first: another * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015..50056eedd03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - it('reads Failed after N, marked failed, when nobody stopped it', async () => { + // The notice row is the one explanation; the turn bar reads like a finished turn. + it('reads Worked for N, marked failed, when nobody stopped it', async () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) + expect(label).toMatch(/^Worked for /) expect(mark).toBe('failed') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 232b101e603..5a0b2f6de30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -193,5 +194,5 @@ function logExitFailure( } function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` + return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` } diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c90544f7502..c75d238dee8 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -4,6 +4,8 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' +import { clearReplacedPaneBinding } from './replaced-pane-binding' import type { PtyBindingSourceExpectation } from './store' @@ -67,6 +69,59 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } + /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ + async retirePtyBinding( + binding: Pick, + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] + const resolved = resolveHostId(hostId) + const publish = (session: WorkspaceSessionState): void => { + if (resolved === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + runtime.dirtyProfileStateDomains?.add( + resolved === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' + ) + } + return runtime.runDurableMutation(() => { + const session = sessions.getWorkspaceSession(resolved) + const currentId = + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + if (!currentId) { + return { value: true, persist: 'if-dirty' } + } + if (currentId !== binding.ptyId) { + return { value: false, persist: false } + } + if (!session.tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId)) { + return { value: false, persist: false } + } + const before = cloneWorkspaceSessionState(session) + const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + // Host retirement must not run renderer snapshot repair, which would put the old binding back. + publish(retired) + const staged = cloneWorkspaceSessionState(retired) + return { + value: true, + rollback: () => { + publish( + rollbackWorkspaceSessionAfterFailedAsyncWrite( + before, + staged, + sessions.getWorkspaceSession(resolved) + ) + ) + } + } + }) + } + async persistPtyBinding( input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), hostId?: string | null diff --git a/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts new file mode 100644 index 00000000000..2f34af1fb0d --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts @@ -0,0 +1,83 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'restart-tab', + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'old-incarnation' +} + +it.each([undefined, 'ssh:restart'])( + 'durably retires only the process binding on %s', + async (hostId) => { + const { store, readState } = await fixture() + await store.persistPtyBinding(binding, hostId) + const before = structuredClone(store.getWorkspaceSession(hostId)) + expect(await store.retirePtyBinding(binding, hostId)).toBe(true) + const retired = store.getWorkspaceSession(hostId) + expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({ + ...before.terminalLayoutsByTabId[binding.tabId], + ptyIdsByLeafId: {} + }) + expect(retired.tabsByWorktree[binding.worktreeId]).toEqual( + before.tabsByWorktree[binding.worktreeId].map((tab) => + tab.id === binding.tabId ? { ...tab, ptyId: null } : tab + ) + ) + const durable = hostId + ? readState().workspaceSessionsByHostId?.[hostId] + : readState().workspaceSession + expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({}) + await store.persistPtyBinding( + { ...binding, ptyId: 'new', incarnationId: 'new-incarnation' }, + hostId + ) + expect(await store.retirePtyBinding(binding, hostId)).toBe(false) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('new') + } +) + +it('clears the stopped id whatever incarnation the binding carries', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' }) + expect(await store.retirePtyBinding(binding)).toBe(true) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual( + {} + ) +}) + +it('restores the old binding after a known save failure and allows another attempt', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('old') + expect(await store.retirePtyBinding(binding)).toBe(true) +}) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts new file mode 100644 index 00000000000..5cefc332677 --- /dev/null +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -0,0 +1,33 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { RetiredTerminalSurface } from '../../runtime/mobile-session-terminal-retirement' + +/** The caller fences the old owner; a restart keeps the pane and removes only its process binding. */ +export function clearReplacedPaneBinding( + session: WorkspaceSessionState, + surface: RetiredTerminalSurface +): WorkspaceSessionState { + const layout = session.terminalLayoutsByTabId[surface.parentTabId] + if (!layout || layout.ptyIdsByLeafId?.[surface.leafId] !== surface.ptyId) { + return session + } + const ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } + delete ptyIdsByLeafId[surface.leafId] + const terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey } + delete terminalPtyIncarnationsByPaneKey[`${surface.parentTabId}:${surface.leafId}`] + return { + ...session, + terminalPtyIncarnationsByPaneKey, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [surface.parentTabId]: { ...layout, ptyIdsByLeafId } + }, + tabsByWorktree: { + ...session.tabsByWorktree, + [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => + tab.id === surface.parentTabId && tab.ptyId === surface.ptyId + ? { ...tab, ptyId: null } + : tab + ) + } + } +} diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index b92b266f427..53d22bad37f 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' // means a call site was added, removed, or moved: re-audit the file and update the count. const AUDITED_NON_NET_FETCH_CALLS = new Map([ // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. - ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + ['main/rate-limits/opencode-go-usage-fetcher.ts', 3], // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. ['main/rate-limits/minimax/minimax-request-context.ts', 2], diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts new file mode 100644 index 00000000000..fb56512bd8c --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +function oauthPrep(): ClaudeRuntimeAuthPreparation { + return { + configDir: '/Users/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } +} + +async function fetchUsageResponse(body: string) { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) + return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) +} + +describe('fetchClaudeRateLimits extra usage', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('maps the usage-credits spend object into a capped balance in major units', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 5000, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 2.5, + enabled: true, + balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 } + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: 10, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + }) + + it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 0, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 0, + enabled: false, + disabled_reason: 'out_of_credits', + balance: null + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 0, + spendLimit: 2000, + spentPercent: 0, + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits' + } + }) + }) + + it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + is_enabled: true, + monthly_limit: 200000, + used_credits: 5000, + utilization: 2.5, + currency: 'EUR', + decimal_places: 2 + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true + } + }) + }) + it('keeps a missing balance unknown while preserving a known cap', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spendLimit: 2000, + currencyCode: 'EUR' + }) + }) + + it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true } + }) + ) + expect(result.extraUsage).toMatchObject({ + balance: amount / 100, + currencyCode: 'EUR', + spendLimit: null + }) + }) + + it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('keeps nullable legacy spend unknown despite a supplied percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: 10 + }) + }) + + it('derives legacy percentage when the response percentage overflows', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('does not turn an overflowing legacy spend into a known amount', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: null + }) + }) + + it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => { + const result = await fetchUsageResponse( + `{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}` + ) + expect(result.extraUsage ?? null).toBeNull() + }) + + it.each([-1, 0.5, 309])( + 'omits malformed legacy precision %s rather than displaying zero', + async (exponent) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + monthly_limit: 200000, + used_credits: 5000, + decimal_places: exponent, + currency: 'EUR', + is_enabled: true + } + }) + ) + expect(result.extraUsage ?? null).toBeNull() + } + ) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..b3bca9298aa 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -4,6 +4,11 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { createOAuthUsageError } from './claude-oauth-usage-error' import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { abortedClaudeRateLimitResult } from './claude-usage-result' +import { + mapClaudeExtraUsage, + type ClaudeOAuthExtraUsage, + type ClaudeOAuthSpend +} from './claude-usage-credits' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const API_TIMEOUT_MS = 10_000 @@ -22,6 +27,8 @@ type OAuthUsageResponse = { fable_weekly?: ClaudeUsageWindowInput fable_seven_day?: ClaudeUsageWindowInput seven_day_fable?: ClaudeUsageWindowInput + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage limits?: OAuthUsageLimit[] | null } @@ -90,6 +97,7 @@ export async function fetchClaudeOAuthUsage( session: mapClaudeUsageWindow(data.five_hour, 300), weekly: mapClaudeUsageWindow(data.seven_day, 10080), fableWeekly: mapFableWeeklyWindow(data), + extraUsage: mapClaudeExtraUsage(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-usage-credits.ts b/src/main/rate-limits/claude-usage-credits.ts new file mode 100644 index 00000000000..9f01fd63064 --- /dev/null +++ b/src/main/rate-limits/claude-usage-credits.ts @@ -0,0 +1,127 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' + +// Money is reported as minor units plus an exponent (e.g. 200000 @ exp 2 = €2000). +type OAuthMoney = { + amount_minor?: number + currency?: string + exponent?: number +} + +// Optional richer spend fields are retained for response compatibility. +export type ClaudeOAuthSpend = { + used?: OAuthMoney + limit?: OAuthMoney + percent?: number + enabled?: boolean + disabled_reason?: string | null + cap?: { money?: OAuthMoney | null } | null + balance?: OAuthMoney | number | null +} + +// Legacy shape kept as a fallback for older responses that only send `extra_usage`. +export type ClaudeOAuthExtraUsage = { + is_enabled?: boolean + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + currency?: string + decimal_places?: number + disabled_reason?: string | null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function moneyToMajor(money: OAuthMoney | number | null | undefined): number | null { + if (typeof money === 'number') { + return Number.isFinite(money) ? money : null + } + if (!money || typeof money.amount_minor !== 'number' || !Number.isFinite(money.amount_minor)) { + return null + } + const exponent = typeof money.exponent === 'number' ? money.exponent : 2 + if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) { + return null + } + return money.amount_minor / 10 ** exponent +} + +// Prefer the richer `spend` object; fall back to the legacy `extra_usage` shape. +export function mapClaudeExtraUsage(data: { + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage +}): ExtraUsageBalance | null { + return mapSpend(data.spend) ?? mapLegacyExtraUsage(data.extra_usage) +} + +function mapSpend(spend: ClaudeOAuthSpend | undefined): ExtraUsageBalance | null { + if (!spend) { + return null + } + const spent = moneyToMajor(spend.used) + const spendLimit = moneyToMajor(spend.limit) ?? moneyToMajor(spend.cap?.money) + const balance = moneyToMajor(spend.balance) + // Nothing worth showing when the account has neither a configured cap nor a balance. + if (spendLimit === null && balance === null) { + return null + } + const spentPercent = + typeof spend.percent === 'number' && Number.isFinite(spend.percent) + ? clampPercent(spend.percent) + : spendLimit !== null && spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance, + unit: 'currency', + currencyCode: + spend.used?.currency ?? + spend.limit?.currency ?? + spend.cap?.money?.currency ?? + (typeof spend.balance === 'object' && spend.balance !== null + ? spend.balance.currency + : undefined) ?? + 'USD', + enabled: spend.enabled === true, + disabledReason: spend.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} + +function mapLegacyExtraUsage(extra: ClaudeOAuthExtraUsage | undefined): ExtraUsageBalance | null { + if (!extra) { + return null + } + const spendLimit = moneyToMajor({ + amount_minor: extra.monthly_limit ?? undefined, + exponent: extra.decimal_places + }) + if (spendLimit === null) { + return null + } + const spent = moneyToMajor({ + amount_minor: extra.used_credits ?? undefined, + exponent: extra.decimal_places + }) + const spentPercent = + typeof extra.utilization === 'number' && Number.isFinite(extra.utilization) + ? clampPercent(extra.utilization) + : spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance: null, + unit: 'currency', + currencyCode: extra.currency?.trim() || 'USD', + enabled: extra.is_enabled === true, + disabledReason: extra.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index aea522e673b..a29cb38d4aa 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -112,7 +112,9 @@ export function mergeClaudeUsageWindows( ...primary, session: primary.session ?? supplement.session, weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, + // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. + extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null } } diff --git a/src/main/rate-limits/codex-credit-balance.ts b/src/main/rate-limits/codex-credit-balance.ts new file mode 100644 index 00000000000..ece52f2df64 --- /dev/null +++ b/src/main/rate-limits/codex-credit-balance.ts @@ -0,0 +1,29 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' +import type { CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' + +// Codex's pay-as-you-go credit balance, reported alongside the rate windows. +// `balance` is a plain credit count (string), not a currency amount. +type RpcCredits = NonNullable + +// Codex credits are a unitless count, not currency. Only surface the balance +// when the account actually has credits (or unlimited) so accounts that never +// bought any don't get an empty "0 credits" row. +export function mapCodexCredits(raw: RpcCredits | null | undefined): ExtraUsageBalance | null { + if (!raw || (raw.hasCredits !== true && raw.unlimited !== true)) { + return null + } + const parsed = + typeof raw.balance === 'string' && raw.balance.trim() !== '' ? Number(raw.balance) : raw.balance + if ((typeof parsed !== 'number' || !Number.isFinite(parsed)) && raw.unlimited !== true) { + return null + } + const balance = typeof parsed === 'number' && Number.isFinite(parsed) ? Math.max(0, parsed) : 0 + return { + balance, + unit: 'credits', + unlimited: raw.unlimited === true, + enabled: raw.hasCredits === true || raw.unlimited === true, + disabledReason: null, + resetsAt: null + } +} diff --git a/src/main/rate-limits/codex-fetcher-credit-balance.test.ts b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts new file mode 100644 index 00000000000..780687c9a09 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) +vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function parseRpcRequest(line: string): { id?: number; method?: string } { + const parsed: unknown = JSON.parse(line) + if (typeof parsed !== 'object' || parsed === null) { + return {} + } + return { + id: 'id' in parsed && typeof parsed.id === 'number' ? parsed.id : undefined, + method: 'method' in parsed && typeof parsed.method === 'string' ? parsed.method : undefined + } +} + +// Like the real app-server, the fake dies on stdin EOF or a signal. +class FakeRpcChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + exitCode: number | null = null + rateLimits: unknown = null + readonly kill = vi.fn(() => { + this.exitNow() + return true + }) + readonly stdin = Object.assign(new EventEmitter(), { + end: vi.fn(() => this.exitNow()), + write: vi.fn((line: string) => { + const request = parseRpcRequest(line) + if (request.method === 'initialize') { + this.reply(request.id, {}) + } + if (request.method === 'account/rateLimits/read') { + this.reply(request.id, { rateLimits: this.rateLimits }) + } + }) + }) + + private reply(id: number | undefined, result: unknown): void { + setTimeout(() => { + this.stdout.emit('data', Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`)) + }, 0) + } + + private exitNow(): void { + this.exitCode = 0 + this.emit('exit', 0, null) + } +} + +async function fetchWithRateLimits(rateLimits: unknown) { + const child = new FakeRpcChild() + child.rateLimits = rateLimits + childSpawnMock.mockReturnValue(child) + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + return resultPromise +} + +describe('fetchCodexRateLimits credit balance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('maps the RPC credits object into a Codex credit-count balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '500' } + }) + + expect(result.extraUsage).toMatchObject({ + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true + }) + }) + + it('omits the Codex balance when the account has no credits', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + }) + + expect(result.extraUsage ?? null).toBeNull() + }) + it.each([undefined, null, '', ' ', 'invalid', 'Infinity', '1e999'])( + 'omits unavailable credit balance %s despite hasCredits', + async (balance) => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance } + }) + expect(result.extraUsage ?? null).toBeNull() + } + ) + + it('preserves an explicitly reported zero credit balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance: '0' } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: false, balance: 0 }) + }) + + it('keeps the unlimited indication without requiring a numeric balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: true, balance: null } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: true, enabled: true }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-window-classification.ts b/src/main/rate-limits/codex-rate-limit-window-classification.ts index cdf667b6306..a19363bed8a 100644 --- a/src/main/rate-limits/codex-rate-limit-window-classification.ts +++ b/src/main/rate-limits/codex-rate-limit-window-classification.ts @@ -13,6 +13,12 @@ export type CodexRateWindowSnapshot = { export type CodexRateLimitWindowsSnapshot = { primary?: CodexRateWindowSnapshot | null secondary?: CodexRateWindowSnapshot | null + /** Pay-as-you-go credit balance, when the app-server reports one. */ + credits?: { + hasCredits?: boolean + unlimited?: boolean + balance?: string | number + } | null } type MappableCodexRateWindowSnapshot = CodexRateWindowSnapshot & { usedPercent: number } diff --git a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts index ededfa9c349..0c59403c35f 100644 --- a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts @@ -12,6 +12,7 @@ import { CODEX_WEEKLY_WINDOW_MINUTES, type CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' +import { mapCodexCredits } from './codex-credit-balance' import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' import { mapCodexRateLimitWindow } from './codex-rate-limit-window-mapper' @@ -244,12 +245,14 @@ export function readCodexRateLimitsViaRpc( const wrapper = message.result as RpcRateLimitsResponse | undefined const classified = classifyCodexRateLimitWindows(wrapper?.rateLimits) const credits = mapRpcRateLimitResetCredits(wrapper?.rateLimitResetCredits) + const extraUsage = mapCodexCredits(wrapper?.rateLimits?.credits) settle( { provider: 'codex', session: mapCodexRateLimitWindow(classified.session, CODEX_SESSION_WINDOW_MINUTES), weekly: mapCodexRateLimitWindow(classified.weekly, CODEX_WEEKLY_WINDOW_MINUTES), ...(credits !== undefined ? { rateLimitResetCredits: credits } : {}), + ...(extraUsage ? { extraUsage } : {}), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts index 1116dcad71b..b77f12f80c8 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.test.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + isOpenCodeGoExplicitNoAccessPayload, + parseOpenCodeGoBillingStatusPayload, parseOpenCodeGoStatusPayload, parseOpenCodeGoUsageApiPayload } from './opencode-go-status-parsing' @@ -120,6 +122,76 @@ describe('parseOpenCodeGoStatusPayload', () => { }) }) +describe('parseOpenCodeGoBillingStatusPayload', () => { + it('maps the verified PAYG micro-cent string into USD major units', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + ).toBe(27.86781005) + }) + + it('preserves zero and negative balances', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + ).toBe(0) + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + ).toBe(-1.25) + }) + + it.each([ + { billingMode: 'credit', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'seat', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'invoiceable', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: 100000000 }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: '1.5' }, + { + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '9007199254740992' + } + ])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => { + expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull() + }) + + it('fails closed for malformed and non-object bodies', () => { + expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull() + }) +}) + +describe('isOpenCodeGoExplicitNoAccessPayload', () => { + it('accepts only explicit JSON null or access:null without an error verdict', () => { + expect(isOpenCodeGoExplicitNoAccessPayload('null')).toBe(true) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":null}')).toBe(true) + expect( + isOpenCodeGoExplicitNoAccessPayload('{"access":null,"error":{"type":"AuthError"}}') + ).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":{}}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{not json')).toBe(false) + }) +}) + describe('parseOpenCodeGoUsageApiPayload', () => { it('clamps an out-of-range percent and tolerates a missing resetsAt', () => { const parsed = parseOpenCodeGoUsageApiPayload( diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts index cc98fd477ed..c4635792fc9 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -1,4 +1,4 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, RateLimitWindow } from '../../shared/rate-limit-types' const SESSION_WINDOW_MINUTES = 300 const WEEKLY_WINDOW_MINUTES = 10_080 @@ -100,6 +100,61 @@ export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWin } } +// Console BillingStatus uses integer-string LedgerMicroCents (100,000,000 per USD). +export function parseOpenCodeGoBillingStatusPayload(text: string): number | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + if ( + !isRecord(payload) || + payload.billingMode !== 'prepaid' || + payload.mode !== 'pay-as-you-go' || + typeof payload.balanceMicroCents !== 'string' || + !/^-?\d+$/.test(payload.balanceMicroCents) + ) { + return null + } + const microCents = Number(payload.balanceMicroCents) + return Number.isSafeInteger(microCents) ? microCents / 100_000_000 : null +} + +export function makeOpenCodeGoZenBalance( + balance: number | null, + disabledReason: string | null = null +): ExtraUsageBalance { + return { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: balance !== null, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } +} + +export function isOpenCodeGoExplicitNoAccessPayload(text: string): boolean { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return false + } + try { + const payload: unknown = JSON.parse(text) + return ( + payload === null || (isRecord(payload) && payload.access === null && !('error' in payload)) + ) + } catch { + return false + } +} + export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { return null diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 9e07f83f472..0c65c2fb788 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -15,6 +15,7 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status' +const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status' const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/ function makeResponse(body: string, status = 200): Response { @@ -259,8 +260,10 @@ describe('fetchOpenCodeGoRateLimits', () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -343,6 +346,254 @@ describe('fetchOpenCodeGoRateLimits', () => { }) }) + it.each(['discovery', 'override'])('accepts an org_ workspace from %s', async (source) => { + netFetchMock.mockImplementation(async (url) => { + if (String(url).startsWith('https://opencode.ai/_server')) { + return makeResponse('id: "org_CONSOLE123"') + } + return url === CONSOLE_STATUS_URL + ? makeJsonResponse(STATUS_WITH_MONTHLY) + : makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + }) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=placeholder', + source === 'override' ? 'org_CONSOLE123' : undefined + ) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage?.balance).toBe(27.86781005) + expect(netFetchMock).toHaveBeenCalledWith( + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ 'x-org-id': 'org_CONSOLE123' }) + }) + ) + }) + + it('enriches the selected cookie workspace with its verified PAYG Zen balance', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_OVERRIDE123', + Accept: 'application/json' + }) + }) + ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') + expect(result.extraUsage).toEqual({ + balance: 27.86781005, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }) + }) + + it.each([ + [ + 'unsupported mode', + makeJsonResponse({ billingMode: 'seat', mode: 'pay-as-you-go' }), + 'billing-unavailable' + ], + ['billing failure', makeResponse('unavailable', 503), 'refresh-failed'] + ])( + 'keeps valid quota when optional billing has an %s', + async (_label, billingResponse, disabledReason) => { + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(billingResponse) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason }) + ) + } + ) + + it('returns a verified balance-only snapshot for explicit access:null', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse({ access: null })).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result).toEqual( + expect.objectContaining({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + error: null, + status: 'ok', + extraUsage: expect.objectContaining({ balance: 0, currencyCode: 'USD' }) + }) + ) + }) + + it('returns a verified balance-only snapshot for an explicit JSON null status', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(null)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session).toBeNull() + expect(result.extraUsage).toEqual(expect.objectContaining({ balance: -1.25 })) + }) + + it('aborts billing before returning a generic malformed-usage error', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse({ access: {} })) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + setTimeout( + () => + resolve( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '100000000' + }) + ), + 100 + ) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(0) + const abortedBeforeBillingResponse = billingSignal?.aborted + await vi.advanceTimersByTimeAsync(100) + const result = await resultPromise + + expect(abortedBeforeBillingResponse).toBe(true) + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('prefers a later valid Go workspace over an earlier balance-only candidate', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse('id: "wrk_FIRST" id: "wrk_SECOND"')) + .mockResolvedValueOnce(makeJsonResponse({ access: null })) + .mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '900000000' + }) + ) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(10) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'refresh-failed' }) + ) + expect(netFetchMock).toHaveBeenNthCalledWith( + 4, + CONSOLE_STATUS_URL, + expect.objectContaining({ headers: expect.objectContaining({ 'x-org-id': 'wrk_SECOND' }) }) + ) + }) + + it('aborts and settles an optional billing timeout before clearing the cookie jar', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(5_000) + const result = await resultPromise + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(billingSignal?.aborted).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('aborts and settles billing before continuing after a non-ok usage response', async () => { + let billingSignal: AbortSignal | undefined + let billingSettled = false + netFetchMock + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener( + 'abort', + () => { + billingSettled = true + reject(new Error('aborted')) + }, + { once: true } + ) + }) + }) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('error') + expect(result.error).toContain('Usage fetch failed (401)') + expect(billingSignal?.aborted).toBe(true) + expect(billingSettled).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) @@ -400,7 +651,7 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') - expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledTimes(2) expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( CONSOLE_STATUS_URL, diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 451d2f316bc..d8ebf61d17d 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,17 +1,24 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' +import { + isOpenCodeGoExplicitNoAccessPayload, + makeOpenCodeGoZenBalance, + parseOpenCodeGoBillingStatusPayload, + parseOpenCodeGoStatusPayload +} from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` +const OPENCODE_BILLING_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/billing/status` const API_TIMEOUT_MS = 15_000 +const OPTIONAL_BILLING_TIMEOUT_MS = 5_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. @@ -59,12 +66,9 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { } function parseWorkspaceIds(text: string): string[] { - // Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output. - // Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a - // more specific regex with word boundaries avoids picking up unrelated - // object properties that might match a generic ID pattern. + // Console uses org_/wrk_; wk_ remains accepted for older workspace responses. const ids: string[] = [] - const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g + const workspaceIdRegex = /\bid\s*:\s*["']((?:org|wrk|wk)_[a-zA-Z0-9]+)["']/g for (const match of text.matchAll(workspaceIdRegex)) { const id = match[1] if (id && !ids.includes(id)) { @@ -126,6 +130,35 @@ export async function fetchOpenCodeGoRateLimits( } } +function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string) { + const abortController = new AbortController() + const promise = (async (): Promise => { + const timeout = setTimeout(() => abortController.abort(), OPTIONAL_BILLING_TIMEOUT_MS) + try { + const response = await openCodeSession.fetch(OPENCODE_BILLING_STATUS_URL, { + method: 'GET', + headers: { + Accept: 'application/json', + Origin: OPENCODE_BASE_URL, + Referer: `${OPENCODE_BASE_URL}/console/${workspaceId}/billing`, + 'x-org-id': workspaceId + }, + signal: abortController.signal + }) + if (!response.ok) { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } + const balance = parseOpenCodeGoBillingStatusPayload(await response.text()) + return makeOpenCodeGoZenBalance(balance, balance === null ? 'billing-unavailable' : null) + } catch { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } finally { + clearTimeout(timeout) + } + })() + return { promise, abort: () => abortController.abort() } +} + function makeOpenCodeError(error: unknown): ProviderRateLimits { return { provider: 'opencode-go', @@ -147,14 +180,14 @@ async function fetchOpenCodeGoRateLimitsWithSession( const override = workspaceIdOverride?.trim() if (override) { - if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) { + if (!/^(org|wrk|wk)_[A-Za-z0-9]+$/.test(override)) { return { provider: 'opencode-go', session: null, weekly: null, monthly: null, updatedAt: Date.now(), - error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$', + error: 'Invalid workspace ID format: must match ^(org|wrk|wk)_[A-Za-z0-9]+$', status: 'error' } } @@ -221,9 +254,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( // Why: /workspace//go now 302s to console login. Usage is JSON at // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' + let balanceOnlyResult: ProviderRateLimits | null = null for (const candidateId of ids) { + let billingTask: ReturnType | null = null try { - const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { + const statusPromise = openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { Accept: 'application/json', @@ -233,8 +268,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - + billingTask = startOptionalBillingFetch(openCodeSession, candidateId) + const statusRes = await statusPromise if (!statusRes.ok) { + billingTask.abort() + await billingTask.promise lastError = statusRes.status === 401 ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' @@ -242,25 +280,56 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) + const statusText = await statusRes.text() + const parsed = parseOpenCodeGoStatusPayload(statusText) + const explicitNoAccess = isOpenCodeGoExplicitNoAccessPayload(statusText) + if (!parsed && !explicitNoAccess) { + billingTask.abort() + await billingTask.promise + lastError = 'Could not parse usage data' + continue + } + + const balance = await billingTask.promise if (parsed) { return { provider: 'opencode-go', session: parsed.session, weekly: parsed.weekly, monthly: parsed.monthly, + extraUsage: balance, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data' + if (balance.balance !== null) { + balanceOnlyResult ??= { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + extraUsage: balance, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } else { + lastError = 'Could not parse usage data' + } } catch (err) { + if (billingTask) { + billingTask.abort() + await billingTask.promise + } const message = err instanceof Error ? err.message : 'Unknown error' lastError = message } } + if (balanceOnlyResult) { + return balanceOnlyResult + } return { provider: 'opencode-go', session: null, diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index 64b7ac622ca..44cc775afa4 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -66,6 +66,9 @@ describe('fetchOpenCodeGoUsage', () => { expect(result.status).toBe('ok') expect(result.session).toEqual(WINDOWS.session) expect(result.usageMetadata?.credentialSource).toBe('environment') + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'api-key-source' }) + ) }) it('passes the settings override down as the highest-precedence tier', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index c38fc1dc844..5973d8c1e91 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -4,7 +4,7 @@ import { resolveOpenCodeGoApiKey, type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' -import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import { makeOpenCodeGoZenBalance, type OpenCodeGoUsageWindows } from './opencode-go-status-parsing' import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, @@ -52,6 +52,7 @@ function usageResult( session: windows.session, weekly: windows.weekly, monthly: windows.monthly, + extraUsage: makeOpenCodeGoZenBalance(null, 'api-key-source'), updatedAt: Date.now(), error: null, status: 'ok', diff --git a/src/main/startup/codex-account-restart-resume.test.ts b/src/main/startup/codex-account-restart-resume.test.ts new file mode 100644 index 00000000000..197c9281edb --- /dev/null +++ b/src/main/startup/codex-account-restart-resume.test.ts @@ -0,0 +1,117 @@ +import { dirname, join } from 'node:path' +import { linkSync, readFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const homes = vi.hoisted(() => ({ original: '', selected: '', system: '' })) +vi.mock('electron', () => ({ app: { getPath: () => homes.system } })) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [homes.original, homes.selected], + resolveSelectedHostAccountCodexHomePathForResume: () => homes.selected || null + }, + store: { getSettings: () => ({}) } + } +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn() + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: vi.fn(), + awaitRealHomeCodexHookTrust: vi.fn() +})) +vi.mock('../codex/codex-home-paths', () => ({ + getCodexSessionBackfillStateDirPath: () => join(homes.system, 'backfill'), + getSystemCodexHomePath: () => homes.system, + getOrcaManagedCodexHomePath: () => join(homes.system, 'legacy') +})) +vi.mock('../codex/codex-config-mirror', () => ({ ensureCodexDaemonSocketGuard: vi.fn() })) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +let root: string +let transcriptPath: string +const sessionId = 'abcdef00-1234-4321-9999-cafecafecafe' +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-account-restart-')) + homes.original = join(root, 'codex-accounts', 'old', 'home') + homes.selected = join(root, 'codex-accounts', 'new', 'home') + homes.system = join(root, 'system') + const relativePath = join('sessions', '2026', '10', '05', `rollout-${sessionId}.jsonl`) + transcriptPath = join(homes.original, relativePath) + const bridged = join(homes.selected, relativePath) + mkdirSync(dirname(transcriptPath), { recursive: true }) + mkdirSync(dirname(bridged), { recursive: true }) + writeFileSync(transcriptPath, '{"type":"session_meta"}\n') + linkSync(transcriptPath, bridged) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('resumes an explicit account restart in the selected home using the bridged transcript', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.selected }) +}) + +it('keeps ordinary automatic restores pinned to their original account', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' } + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.original }) +}) + +it('materializes a rollout when the account bridge has not caught up', async () => { + rmSync(join(homes.selected, 'sessions'), { recursive: true }) + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.selected }) + expect(readFileSync(transcriptPath.replace(homes.original, homes.selected), 'utf8')).toBe( + readFileSync(transcriptPath, 'utf8') + ) +}) + +it('refuses a conflicting target rollout instead of restarting the old account', async () => { + const target = transcriptPath.replace(homes.original, homes.selected) + rmSync(target) + writeFileSync(target, 'another session') + await expect( + prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + ).rejects.toThrow('different rollout') + expect(readFileSync(target, 'utf8')).toBe('another session') +}) + +it('uses the system home when an explicit restart follows deselection', async () => { + homes.selected = '' + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.system }) +}) + +it('leaves WSL preparation on the execution host', async () => { + expect( + await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + useSelectedAccount: true + }) + ).toBeNull() +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index f01d17610d7..5feb61df417 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -3,7 +3,10 @@ import type { AgentProviderSessionMetadata } from '../../shared/agent-session-re import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' -import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { + prepareCodexAccountRestartResume, + prepareLegacySharedCodexSessionResume +} from '../codex/codex-legacy-session-resume' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { @@ -20,6 +23,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store @@ -66,12 +70,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { } ) } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. + // A credential-read refusal must never fall back to the old account. if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { throw error } @@ -81,7 +80,16 @@ export async function prepareCodexSessionResumeForLaunch(args: { error ) } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + const resumeHome = args.useSelectedAccount + ? await prepareCodexAccountRestartResume({ + sourceHome: sessionSource.homePath, + transcriptPath: sessionSource.transcriptPath, + targetHome: selectedAccountCodexHome ?? systemHomePath, + systemCodexHomePath: systemHomePath + }) + : migrated.useRealCodexHome + ? systemHomePath + : sessionSource.homePath const isSystemHome = normalizeRuntimePathForComparison(resumeHome) === normalizeRuntimePathForComparison(systemHomePath) diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx deleted file mode 100644 index 40298758d6e..00000000000 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { detectCsvDelimiter, parseCsv } from './csv-parse' -import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' -import { CsvGrid } from './CsvGrid' -import { translate } from '@/i18n/i18n' -import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index' -import { openCsvHttpLink } from './csv-link-routing' -import { CSV_PAGED_PREVIEW_BYTES } from './editor-csv-file-content' - -export default function CsvViewer({ - content, - filePath, - worktreeId, - runtimeEnvironmentId -}: { - content: string - filePath: string - worktreeId?: string - runtimeEnvironmentId?: string | null -}): React.JSX.Element { - const [delimiterChoice, setDelimiterChoice] = useState('auto') - const detectedDelimiter = useMemo( - () => detectCsvDelimiter(filePath, content), - [filePath, content] - ) - const delimiter = csvChosenDelimiter(delimiterChoice, detectedDelimiter) - const result = useMemo(() => { - try { - return { - parsed: parseCsv(content, delimiter, { - maxCells: CSV_PAGED_PREVIEW_BYTES + 1, - maxColumns: CSV_MAX_COLUMNS, - maxRecordLength: CSV_RECORD_BYTES - }), - error: null - } - } catch (error) { - return { parsed: null, error: error instanceof Error ? error.message : String(error) } - } - }, [content, delimiter]) - const header = result.parsed?.rows[0] ?? [] - const rowCount = Math.max(0, (result.parsed?.rows.length ?? 0) - 1) - const sample = useMemo(() => result.parsed?.rows.slice(1, 201) ?? [], [result.parsed]) - const getRow = useCallback((index: number) => result.parsed?.rows[index + 1], [result.parsed]) - return ( -

- {result.error ? ( -
- {result.error} -
- ) : result.parsed?.rows.length ? ( - - openCsvHttpLink(url, event, { filePath, worktreeId, runtimeEnvironmentId }) - } - /> - ) : ( -
- {translate('auto.components.editor.CsvViewer.a233d55b77', 'Empty file')} -
- )} - -
- ) -} - -export function csvChosenDelimiter(choice: CsvDelimiterChoice, detected: string): string { - return choice === 'auto' - ? detected - : choice === 'comma' - ? ',' - : choice === 'semicolon' - ? ';' - : '\t' -} - -export function CsvFooter({ - rowCount, - columnCount, - delimiterChoice, - detectedDelimiter, - onDelimiterChange -}: { - rowCount: number - columnCount: number - delimiterChoice: CsvDelimiterChoice - detectedDelimiter: string - onDelimiterChange: (choice: CsvDelimiterChoice) => void -}): React.JSX.Element { - return ( -
- - {rowCount.toLocaleString()}{' '} - {translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')} - - - {columnCount.toLocaleString()}{' '} - {translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')} - - -
- ) -} diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx index b7b04bb59fd..19630186727 100644 --- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -106,6 +106,7 @@ export function EditorEditFileSurface({ reloadContent(activeFile)} /> @@ -260,6 +261,12 @@ export function EditorEditFileSurface({ filePath={activeFile.filePath} worktreeId={activeFile.worktreeId} runtimeEnvironmentId={activeFile.runtimeEnvironmentId} + preferenceKey={pdfPreferenceKey} + fileId={activeFile.id} + isDirty={activeFile.isDirty} + onDirtyStateHint={activeFile.readOnly ? undefined : handleDirtyStateHint} + onContentChange={activeFile.readOnly ? undefined : handleContentChange} + onSave={activeFile.readOnly ? undefined : handleSave} /> ) : isNotebook && mdViewMode === 'rich' ? ( = } } -// Why: CSV/TSV files reuse the 'rich' view mode slot but the rendered surface -// is a read-only table, not an editor. The Pencil icon implies editability, -// which we don't offer, so callers can override the per-mode presentation. +// CSV/TSV uses a table icon for both editable tables and read-only previews. export const CSV_VIEW_MODE_METADATA: Partial> = { rich: { get label() { diff --git a/src/renderer/src/components/editor/CsvCellValue.tsx b/src/renderer/src/components/editor/csv/CsvCellValue.tsx similarity index 100% rename from src/renderer/src/components/editor/CsvCellValue.tsx rename to src/renderer/src/components/editor/csv/CsvCellValue.tsx diff --git a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx b/src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx similarity index 93% rename from src/renderer/src/components/editor/CsvColumnResizeHandle.tsx rename to src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx index ec2bca0f629..0efdb568c70 100644 --- a/src/renderer/src/components/editor/CsvColumnResizeHandle.tsx +++ b/src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef } from 'react' import { translate } from '@/i18n/i18n' - -export const CSV_MIN_COLUMN_WIDTH = 48 -export const CSV_MAX_COLUMN_WIDTH = 1200 +import { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' +export { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' export function CsvColumnResizeHandle({ index, diff --git a/src/renderer/src/components/editor/CsvGrid.test.tsx b/src/renderer/src/components/editor/csv/CsvGrid.test.tsx similarity index 100% rename from src/renderer/src/components/editor/CsvGrid.test.tsx rename to src/renderer/src/components/editor/csv/CsvGrid.test.tsx diff --git a/src/renderer/src/components/editor/CsvGrid.tsx b/src/renderer/src/components/editor/csv/CsvGrid.tsx similarity index 51% rename from src/renderer/src/components/editor/CsvGrid.tsx rename to src/renderer/src/components/editor/csv/CsvGrid.tsx index 0f6eee7df75..9afe01942ad 100644 --- a/src/renderer/src/components/editor/CsvGrid.tsx +++ b/src/renderer/src/components/editor/csv/CsvGrid.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { CsvColumnResizeHandle } from './CsvColumnResizeHandle' -import { CsvCellValue } from './CsvCellValue' +import { CsvGridCell } from './CsvGridCell' +import type { CsvGridInteraction } from './csv-grid-interaction' +import type { CsvColumnWidths } from './csv-column-width-preferences' const ROW_HEIGHT = 28 // Keep the scroll surface below Chromium's layout extent limit. @@ -16,7 +18,10 @@ export function CsvGrid({ sampleRows, getRow, onVisibleRows, - onOpenUrl + onOpenUrl, + interaction, + columnWidths, + onColumnWidthsChange }: { header: string[] rowCount: number @@ -25,14 +30,23 @@ export function CsvGrid({ getRow: (index: number) => string[] | undefined onVisibleRows?: (first: number, last: number) => void onOpenUrl?: (url: string, event: React.MouseEvent) => void + interaction?: CsvGridInteraction + columnWidths?: CsvColumnWidths + onColumnWidthsChange?: (widths: CsvColumnWidths) => void }): React.JSX.Element { const scrollRef = useRef(null) + const gridRef = useRef(null) + const gridId = useId() + const dragging = useRef(false) + const scrolledFocusVersion = useRef(-1) + const focusGrid = (): void => gridRef.current?.focus({ preventScroll: true }) const [requestedWindowStart, setWindowStart] = useState(0) const windowStart = Math.min( requestedWindowStart, Math.floor(Math.max(0, rowCount - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS ) - const [widthOverrides, setWidthOverrides] = useState>({}) + const [localWidths, setLocalWidths] = useState({}) + const widthOverrides = columnWidths ?? localWidths const rowNumberWidth = Math.max(48, String(rowCount).length * 8 + 16) const widths = useMemo(() => { const result = Array.from({ length: columnCount }, () => 80) @@ -60,7 +74,7 @@ export function CsvGrid({ paddingStart: rowNumberWidth, overscan: 2 }) - useLayoutEffect(() => columns.measure(), [columns, widths]) + useLayoutEffect(() => columns.measure(), [columns, widths, columnWidths]) const virtualRows = rows.getVirtualItems() const virtualColumns = columns.getVirtualItems() const first = virtualRows[0]?.index @@ -72,12 +86,82 @@ export function CsvGrid({ }, [first, last, windowStart, onVisibleRows]) const resize = useCallback( (index: number, width: number) => { - setWidthOverrides((previous) => ({ ...previous, [index]: width })) + const next = { ...widthOverrides, [index]: width } + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } columns.resizeItem(index, width) }, - [columns] + [columns, onColumnWidthsChange, widthOverrides] ) - const reset = useCallback((index: number) => resize(index, widths[index] ?? 80), [resize, widths]) + const reset = (index: number): void => { + const next = { ...widthOverrides } + delete next[index] + if (onColumnWidthsChange) { + onColumnWidthsChange(next) + } else { + setLocalWidths(next) + } + columns.resizeItem(index, widths[index] ?? 80) + } + const active = interaction?.selection?.focus + useLayoutEffect(() => { + if (!active || scrolledFocusVersion.current === interaction?.focusVersion) { + return + } + if (interaction?.editing) { + scrolledFocusVersion.current = interaction.focusVersion + return + } + const targetWindow = + Math.floor(Math.max(0, active.row - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS + if (targetWindow !== windowStart) { + setWindowStart(targetWindow) + return + } + if (active.row > 0) { + rows.scrollToIndex(active.row - 1 - windowStart, { align: 'auto' }) + } else { + scrollRef.current?.scrollTo({ top: 0 }) + } + columns.scrollToIndex(active.column, { align: 'auto' }) + scrolledFocusVersion.current = interaction?.focusVersion ?? -1 + }, [active, interaction?.editing, interaction?.focusVersion, rows, columns, windowStart]) + useEffect(() => { + const finish = (): void => { + dragging.current = false + } + window.addEventListener('pointerup', finish) + window.addEventListener('pointercancel', finish) + window.addEventListener('blur', finish) + return () => { + window.removeEventListener('pointerup', finish) + window.removeEventListener('pointercancel', finish) + window.removeEventListener('blur', finish) + } + }, []) + const pointerCell = (event: React.PointerEvent): { row: number; column: number } | null => { + if ( + !(event.target instanceof Element) || + event.target.closest('a,textarea,[role="separator"]') + ) { + return null + } + const cell = event.target.closest('[data-csv-row]') + if (!cell) { + return null + } + const row = Number(cell.getAttribute('data-csv-row')) + const column = Number(cell.getAttribute('data-csv-column')) + return Number.isInteger(row) && Number.isInteger(column) ? { row, column } : null + } + const activeMounted = + active && + virtualColumns.some((column) => column.index === active.column) && + (active.row === 0 || virtualRows.some((row) => row.index + windowStart + 1 === active.row)) + const totalWidth = columns.getTotalSize() const gridTemplate = `${rowNumberWidth}px ${Math.max(0, (virtualColumns[0]?.start ?? rowNumberWidth) - rowNumberWidth)}px ${virtualColumns.map((column) => `${column.size}px`).join(' ')} ${Math.max(0, totalWidth - (virtualColumns.at(-1)?.end ?? rowNumberWidth))}px` const moveWindow = (start: number): void => { @@ -90,9 +174,65 @@ export function CsvGrid({ ref={scrollRef} data-testid="csv-scroll" className="relative min-h-0 flex-1 overflow-auto scrollbar-editor font-mono text-xs" + onScroll={(event) => { + if (!interaction?.editing) { + return + } + const position = interaction.editing.position + const cell = gridRef.current + ?.querySelector( + `[data-csv-row="${position.row}"][data-csv-column="${position.column}"]` + ) + ?.getBoundingClientRect() + const viewport = event.currentTarget.getBoundingClientRect() + // Caret visibility can scroll a clipped input while the user is still typing. + if ( + cell && + cell.bottom > viewport.top + (position.row ? ROW_HEIGHT : 0) && + cell.top < viewport.bottom && + cell.right > viewport.left && + cell.left < viewport.right + ) { + return + } + if (!interaction.commit()) { + if (position.row > 0) { + rows.scrollToIndex(position.row - 1 - windowStart, { align: 'auto' }) + } + columns.scrollToIndex(position.column, { align: 'auto' }) + } + }} >
{ + const cell = pointerCell(event) + if (!interaction || !cell || event.button !== 0) { + return + } + event.preventDefault() + if (interaction.select(cell.row, cell.column, event.shiftKey)) { + dragging.current = true + focusGrid() + } + }} + onPointerMove={(event) => { + const cell = pointerCell(event) + if (interaction && dragging.current && cell) { + interaction.select(cell.row, cell.column, true) + } + }} aria-rowcount={rowCount + 1} aria-colcount={columnCount + 1} className="relative min-w-full" @@ -113,23 +253,23 @@ export function CsvGrid({