Merge #23962's new head (main 40f9e7a3fc) into brennanb2025/launch-prompt-argv-carry

This commit is contained in:
Brennan Benson
2026-10-05 02:29:22 -07:00
182 changed files with 9438 additions and 790 deletions
+1 -1
View File
@@ -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,
@@ -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 },
+39 -5
View File
@@ -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<ComponentType<TextProps>>(NativeText)
type MarkdownTextSetup = {
TextComponent: ComponentType<TextProps>
/** Disable native selection only within the Android transcript. */
androidTranscript: boolean
onLongPress?: () => void
}
const MarkdownTextContext = createContext<MarkdownTextSetup>({
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}
>
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
@@ -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<MarkdownTextSetup>(
() => ({
TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText,
androidTranscript,
...(onLongPress ? { onLongPress } : {})
}),
[rangeSelectable, androidTranscript, onLongPress]
)
return (
<MarkdownTextContext.Provider value={TextComponent}>
<MarkdownTextContext.Provider value={setup}>
<MobileMarkdownContent {...props} />
</MarkdownTextContext.Provider>
)
@@ -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)
})
})
@@ -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)
@@ -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<typeof MobileMarkdown>[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)
})
})
@@ -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: <T>(styles: T) => styles, hairlineWidth: 1 },
@@ -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 },
@@ -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)
}
})
})
@@ -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'
+51 -7
View File
@@ -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 (
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
>
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
</Text>
)
@@ -39,7 +47,10 @@ function Prose({
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}
>
{block.text}
</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<typeof View>['style']
children: ReactNode
}): React.JSX.Element {
return onLongPress ? (
<Pressable onLongPress={onLongPress} style={style}>
{children}
</Pressable>
) : (
<View style={style}>{children}</View>
)
}
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 ? (
<MobileNativeChatTurnStatus
@@ -144,7 +180,8 @@ function MobileNativeChatMessageImpl({
{/* A turn with no user bubble carries its bar above its first row. */}
{turnStatusAbove ? statusRow : null}
<View style={[styles.row, isUser && styles.rowUser]}>
<View
<Content
onLongPress={onLongPress}
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
>
{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}
</View>
</Content>
</View>
{actionsOpen ? (
<MobileNativeChatMessageActionsSheet
message={message}
onClose={() => setActionsOpen(false)}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
@@ -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()
})
})
@@ -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 (
<>
<BottomDrawer
visible={sheetVisible}
onClose={closeSheet}
onAfterClose={() => (selectRequested.current ? setSelecting(true) : onClose())}
dragContentToDismiss
>
<ActionSheetContent
onClose={closeSheet}
actions={[
{
label: 'Copy message',
icon: Copy,
disabled: text.length === 0,
onPress: () => {
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
}
}
]}
/>
</BottomDrawer>
{selecting ? <SelectTextScreen text={text} onClose={onClose} /> : null}
</>
)
}
function SelectTextScreen({
text,
onClose
}: {
text: string
onClose: () => void
}): React.JSX.Element {
const insets = useSafeAreaInsets()
return (
<Modal visible animationType="slide" onRequestClose={onClose}>
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<Text style={styles.title}>Select text</Text>
<Pressable
onPress={onClose}
hitSlop={12}
accessibilityLabel="Close"
accessibilityRole="button"
style={styles.close}
>
<X size={20} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.text}>
{text}
</Text>
</ScrollView>
</View>
</Modal>
)
}
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 }
})
@@ -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(),
@@ -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('')
})
})
@@ -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<NativeChatMessage, 'blocks'>): 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')
}
@@ -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<RpcClient, 'sendRequest' | 'subscribe'> = {
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
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<typeof useMobileStructuredAgentSession> | 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 })
})
})
@@ -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)
@@ -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<string> {
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.
+374 -20
View File
@@ -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<typeof installRestartHarness>
type FakeSession = {
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
terminalLayoutsByTabId: Record<
string,
{
root: { type: 'leaf'; leafId: string }
activeLeafId: string
expandedLeafId: null
ptyIdsByLeafId: Record<string, string>
}
>
terminalPtyIncarnationsByPaneKey: Record<string, string>
}
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<void> } = {}
) {
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<typeof setLocalPtyProvider>[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 <T>(mutate: () => { value: T }) => mutate().value),
persistPtyBinding: vi.fn(),
runDurableMutation: vi.fn(
async <T>(
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<string, FakeSession>
} = { 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<unknown>
) => Promise<void>
): Promise<void> {
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<typeof registerSshPtyProvider>[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<typeof setLocalPtyProvider>[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
})
}
)
})
+3 -1
View File
@@ -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 } : {})
})
}
}
+1
View File
@@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => Promise<CodexSessionResumePreparation | null>
export type CodexHomePtySpawnedLifecycleArgs = {
+20 -13
View File
@@ -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
+8 -3
View File
@@ -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<P
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
origin: spawnCommitBindingOrigin(ctx.result)
}
const persisted = args.connectionId
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
: await ctx.deps.store.persistPtyBinding(binding)
const hostId = args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
const input = ctx.replacedPaneOwner
? swapReplacedPaneBinding(ctx.deps.store, binding, ctx.replacedPaneOwner, hostId)
: binding
const persisted = hostId
? await ctx.deps.store.persistPtyBinding(input, hostId)
: await ctx.deps.store.persistPtyBinding(input)
if (persisted === false) {
throw new Error('terminal_pane_owner_changed')
}
+2 -1
View File
@@ -39,7 +39,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
launchAgent: args.launchAgent,
providerSession: args.resumeProviderSession,
target: ctx.codexSelectionTarget,
launchEnv: ctx.baseEnv
launchEnv: ctx.baseEnv,
...(args.replacesPtyId ? { useSelectedAccount: true } : {})
})
ctx.codexResumeLaunch = codexResumePreparation
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
+15 -15
View File
@@ -10,6 +10,7 @@ import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import { agentKindSchema } from '../../../../shared/telemetry-events'
import { normalizeNodePtySpawnError } from '../provider/liveness'
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
import { deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
@@ -23,13 +24,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
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<void> {
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
+15 -4
View File
@@ -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
+3
View File
@@ -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,
+1
View File
@@ -107,6 +107,7 @@ export type PtySpawnIpcDeps = {
providerSession?: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => PreparedCodexResumeHome | null
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
resolveCodexResumeLaunch: (
@@ -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<ReturnType<typeof fixture>>['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<void> {
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')
})
@@ -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<void>
},
args: {
replacesPtyId: string
paneKey: string | null
worktreeId: string | undefined
connectionId: string | null | undefined
}
): Promise<ReplacedPaneOwner> {
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<void> {
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)
}
}
@@ -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)(
@@ -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 () => {
@@ -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.
@@ -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'
)
}
)
@@ -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 }
@@ -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,
@@ -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. */
@@ -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')
@@ -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}`
}
@@ -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<PersistPtyBindingArgs, 'worktreeId' | 'tabId' | 'leafId' | 'ptyId'>,
hostId?: string | null
): Promise<boolean> {
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
@@ -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)
})
@@ -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
)
}
}
}
@@ -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<string, number>([
// 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],
@@ -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()
}
)
})
@@ -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'
@@ -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
}
}
+3 -1
View File
@@ -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
}
}
@@ -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<CodexRateLimitWindowsSnapshot['credits']>
// 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
}
}
@@ -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 })
})
})
@@ -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 }
@@ -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'
@@ -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('<html></html>')).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(
@@ -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
@@ -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<Response>((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<Response>((_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<Response>((_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,
@@ -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<ExtraUsageBalance> => {
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/<id>/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<typeof startOptionalBillingFetch> | 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,
@@ -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 () => {
@@ -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',
@@ -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()
})
@@ -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<CodexSessionResumePreparation | null> {
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)
@@ -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<CsvDelimiterChoice>('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 (
<div className="flex h-full min-h-0 flex-col">
{result.error ? (
<div
role="alert"
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
>
{result.error}
</div>
) : result.parsed?.rows.length ? (
<CsvGrid
key={delimiter}
header={header}
rowCount={rowCount}
columnCount={result.parsed.maxColumns}
sampleRows={sample}
getRow={getRow}
onOpenUrl={(url, event) =>
openCsvHttpLink(url, event, { filePath, worktreeId, runtimeEnvironmentId })
}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate('auto.components.editor.CsvViewer.a233d55b77', 'Empty file')}
</div>
)}
<CsvFooter
rowCount={rowCount}
columnCount={result.parsed?.maxColumns ?? 0}
delimiterChoice={delimiterChoice}
detectedDelimiter={detectedDelimiter}
onDelimiterChange={setDelimiterChoice}
/>
</div>
)
}
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 (
<div className="flex flex-wrap items-center gap-4 border-t border-border/60 px-3 py-1 text-xs text-muted-foreground">
<span>
{rowCount.toLocaleString()}{' '}
{translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')}
</span>
<span>
{columnCount.toLocaleString()}{' '}
{translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')}
</span>
<CsvDelimiterPicker
value={delimiterChoice}
detectedDelimiter={detectedDelimiter}
onChange={onDelimiterChange}
/>
</div>
)
}
@@ -106,6 +106,7 @@ export function EditorEditFileSurface({
<CsvPagedViewer
key={activeFile.id}
file={fileContent.csvPreview}
preferenceKey={pdfPreferenceKey}
filePath={activeFile.filePath}
onReload={() => 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' ? (
<IpynbViewer
@@ -66,9 +66,7 @@ const DEFAULT_VIEW_MODE_METADATA: Record<EditorToggleValue, ViewModeMetadata> =
}
}
// 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<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
get label() {
@@ -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,
@@ -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<HTMLAnchorElement>) => void
interaction?: CsvGridInteraction
columnWidths?: CsvColumnWidths
onColumnWidthsChange?: (widths: CsvColumnWidths) => void
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(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<Record<number, number>>({})
const [localWidths, setLocalWidths] = useState<CsvColumnWidths>({})
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' })
}
}}
>
<div
role="table"
ref={gridRef}
role={interaction ? 'grid' : 'table'}
data-testid="csv-grid"
data-csv-owner={interaction?.ownerId}
tabIndex={interaction ? 0 : undefined}
aria-label={interaction ? translate('csv.editor', 'CSV editor') : undefined}
aria-activedescendant={
activeMounted ? `${gridId}-${active.row}-${active.column}` : undefined
}
onKeyDown={interaction?.keyDown}
onCopy={interaction?.copy}
onPaste={interaction?.paste}
onPointerDown={(event) => {
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({
</div>
<div aria-hidden="true" />
{virtualColumns.map((column) => (
<div
role="columnheader"
aria-colindex={column.index + 2}
aria-label={header[column.index] ?? ''}
<CsvGridCell
key={column.key}
className="relative flex min-w-0 items-center border-b border-r border-border/60 px-2 font-medium text-foreground"
id={`${gridId}-0-${column.index}`}
header
value={header[column.index] ?? ''}
row={0}
column={column.index}
interaction={interaction}
focusGrid={focusGrid}
>
<span className="truncate" title={header[column.index] ?? ''}>
{header[column.index] ?? ''}
</span>
<CsvColumnResizeHandle
index={column.index}
width={column.size}
onResize={resize}
onReset={reset}
/>
</div>
</CsvGridCell>
))}
<div aria-hidden="true" />
</div>
@@ -164,20 +304,18 @@ export function CsvGrid({
</div>
<div aria-hidden="true" />
{virtualColumns.map((column) => (
<div
role="cell"
aria-colindex={column.index + 2}
<CsvGridCell
key={column.key}
className="flex min-w-0 items-center overflow-hidden border-b border-r border-border/40 px-2 text-foreground"
title={row?.[column.index] ?? ''}
>
<CsvCellValue
value={
row ? (row[column.index] ?? '') : translate('csv.loadingCell', 'Loading…')
}
onOpenUrl={onOpenUrl}
/>
</div>
id={`${gridId}-${index + 1}-${column.index}`}
value={
row ? (row[column.index] ?? '') : translate('csv.loadingCell', 'Loading…')
}
row={index + 1}
column={column.index}
interaction={interaction}
focusGrid={focusGrid}
onOpenUrl={onOpenUrl}
/>
))}
<div aria-hidden="true" />
</div>
@@ -0,0 +1,106 @@
import { Textarea } from '@/components/ui/textarea'
import { CsvCellValue } from './CsvCellValue'
import { csvCellIsSelected } from './csv-cell-selection'
import type { CsvGridInteraction } from './csv-grid-interaction'
import { translate } from '@/i18n/i18n'
function focusCellInput(node: HTMLTextAreaElement | null): void {
node?.focus({ preventScroll: true })
}
export function CsvGridCell({
value,
row,
column,
id,
header = false,
interaction,
onOpenUrl,
focusGrid,
children
}: {
value: string
row: number
column: number
id: string
header?: boolean
interaction?: CsvGridInteraction
onOpenUrl?: (url: string, event: React.MouseEvent<HTMLAnchorElement>) => void
focusGrid: () => void
children?: React.ReactNode
}): React.JSX.Element {
const editing = interaction?.editing
const active = editing?.position.row === row && editing.position.column === column
return (
<div
id={id}
role={header ? 'columnheader' : interaction ? 'gridcell' : 'cell'}
aria-colindex={column + 2}
aria-label={header ? value : undefined}
aria-selected={
interaction ? csvCellIsSelected(interaction.selection, row, column) : undefined
}
data-active={
interaction?.selection?.focus.row === row && interaction.selection.focus.column === column
}
data-csv-row={row}
data-csv-column={column}
className={
header
? 'relative flex min-w-0 items-center border-b border-r border-border/60 font-medium text-foreground aria-selected:bg-accent data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-ring'
: 'flex min-w-0 items-center overflow-hidden border-b border-r border-border/40 text-foreground aria-selected:bg-accent data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-ring'
}
title={value}
onDoubleClick={(event) => {
if (event.target instanceof Element && event.target.closest('a,[role="separator"]')) {
return
}
interaction?.edit(row, column)
}}
>
{active && editing ? (
<Textarea
ref={focusCellInput}
variant="cell"
rows={1}
aria-label={translate('csv.editCell', 'Edit row {{row}}, column {{column}}', {
row: row + 1,
column: column + 1
})}
value={editing.value}
onChange={(event) => interaction?.change(event.target.value)}
onBlur={() => interaction?.commit()}
onKeyDown={(event) => {
event.stopPropagation()
if (event.nativeEvent.isComposing) {
return
}
const modifier = navigator.userAgent.includes('Mac') ? event.metaKey : event.ctrlKey
if (modifier && event.key.toLowerCase() === 's') {
event.preventDefault()
interaction?.save()
} else if (event.key === 'Escape') {
event.preventDefault()
interaction?.cancel()
focusGrid()
} else if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
event.preventDefault()
if (interaction?.commit()) {
focusGrid()
}
}
}}
/>
) : (
<div className="flex min-w-0 flex-1 items-center px-2">
{header ? (
<span className="truncate">{value}</span>
) : (
<CsvCellValue value={value} onOpenUrl={onOpenUrl} />
)}
</div>
)}
{children}
</div>
)
}
@@ -4,19 +4,23 @@ import { translate } from '@/i18n/i18n'
import { detectCsvDelimiter } from './csv-parse'
import { CsvFooter, csvChosenDelimiter } from './CsvViewer'
import { CsvGrid } from './CsvGrid'
import type { CsvFilePreview } from './editor-csv-file-content'
import type { CsvFilePreview } from './csv-file-content'
import type { CsvDelimiterChoice } from './csv-delimiter-picker'
import { useCsvPagedPreview } from './useCsvPagedPreview'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import { EditorFileLoadErrorView } from '../EditorFileLoadErrorView'
import { useCsvColumnWidths } from './useCsvColumnWidths'
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
import { openCsvHttpLink } from './csv-link-routing'
export default function CsvPagedViewer({
file,
filePath,
preferenceKey,
onReload
}: {
file: CsvFilePreview
filePath: string
preferenceKey?: string
onReload: () => void
}): React.JSX.Element {
const [sniff, setSniff] = useState<{ delimiter: string; error: string | null } | null>(null)
@@ -56,6 +60,7 @@ export default function CsvPagedViewer({
<CsvPagedTable
key={csvChosenDelimiter(choice, sniff.delimiter)}
file={file}
preferenceKey={preferenceKey}
delimiter={csvChosenDelimiter(choice, sniff.delimiter)}
choice={choice}
detected={sniff.delimiter}
@@ -73,6 +78,7 @@ export default function CsvPagedViewer({
function CsvPagedTable({
file,
preferenceKey,
delimiter,
choice,
detected,
@@ -80,12 +86,24 @@ function CsvPagedTable({
onReload
}: {
file: CsvFilePreview
preferenceKey?: string
delimiter: string
choice: CsvDelimiterChoice
detected: string
onChange: (choice: CsvDelimiterChoice) => void
onReload: () => void
}): React.JSX.Element {
const storedKey = JSON.stringify([
preferenceKey ??
buildFileViewPreferenceKey({
worktreeId: file.readArgs.worktreeId ?? '',
runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId,
externalSshTargetId: file.readArgs.connectionId,
filePath: file.readArgs.filePath
}),
delimiter
])
const { widths, changeWidths } = useCsvColumnWidths(storedKey)
const preview = useCsvPagedPreview(file, delimiter)
const rowCount = Math.max(0, (preview.index?.rowCount ?? 0) - 1)
return (
@@ -95,6 +113,8 @@ function CsvPagedTable({
) : preview.index ? (
<CsvGrid
header={preview.header}
columnWidths={widths}
onColumnWidthsChange={changeWidths}
rowCount={rowCount}
columnCount={preview.index.columnCount}
sampleRows={preview.sample}
@@ -0,0 +1,193 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection'
import type { CsvTableMutation } from './csv-text-document'
import type { CsvInspectionSort } from './csv-inspection'
export function CsvTableToolbar({
selection,
getSelectedRows,
sourceRow,
sourceRowCount,
columnCount,
filter,
sort,
saving,
canEdit,
canSave,
onFilter,
onSort,
onApply,
onEdit,
onSave,
onCopy,
onPaste
}: {
selection: CsvCellSelection | null
getSelectedRows: () => number[]
sourceRow: number
sourceRowCount: number
columnCount: number
filter: string
sort: CsvInspectionSort | null
saving: boolean
canEdit: boolean
canSave: boolean
onFilter: (value: string) => void
onSort: (value: CsvInspectionSort | null) => void
onApply: (mutation: CsvTableMutation) => boolean
onEdit: () => void
onSave: () => void
onCopy: () => void
onPaste: () => void
}): React.JSX.Element {
const column = selection?.focus.column ?? 0
const row = Math.max(1, sourceRow)
const blankRow = () => Array.from({ length: columnCount }, () => '')
const selectedColumns = selection ? csvSelectionBounds(selection) : null
const deleteColumns = () =>
selectedColumns
? Array.from(
{ length: selectedColumns.lastColumn - selectedColumns.firstColumn + 1 },
(_, offset) => selectedColumns.firstColumn + offset
)
: []
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1 max-w-xs">
<Input
aria-label={translate('csv.filterRows', 'Filter rows')}
placeholder={translate('csv.filterRows', 'Filter rows')}
value={filter}
onChange={(event) => onFilter(event.target.value)}
/>
</div>
<Button variant="ghost" size="xs" disabled={!selection || !canEdit} onClick={onEdit}>
{translate('csv.edit', 'Edit cell')}
</Button>
<Button variant="ghost" size="xs" disabled={!selection} onClick={onCopy}>
{translate('csv.copy', 'Copy')}
</Button>
<Button variant="ghost" size="xs" disabled={!selection || !canEdit} onClick={onPaste}>
{translate('csv.paste', 'Paste')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" disabled={!canEdit || !columnCount}>
{translate('csv.rows', 'Rows')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onSelect={() => onApply({ kind: 'insert-rows', at: row, rows: [blankRow()] })}
>
{translate('csv.insertRowAbove', 'Insert row above')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
onApply({
kind: 'insert-rows',
at: sourceRow === 0 ? 1 : Math.min(row + 1, sourceRowCount),
rows: [blankRow()]
})
}
>
{translate('csv.insertRowBelow', 'Insert row below')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!selection || csvSelectionBounds(selection).lastRow === 0}
onSelect={() =>
onApply({ kind: 'delete-rows', rows: getSelectedRows().filter((row) => row > 0) })
}
>
{translate('csv.deleteRows', 'Delete selected rows')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" disabled={!canEdit}>
{translate('csv.columns', 'Columns')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onSelect={() =>
onApply({
kind: 'insert-column',
at: column,
label: translate('csv.newColumn', 'Column {{column}}', { column: columnCount + 1 })
})
}
>
{translate('csv.insertColumnBefore', 'Insert column before')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
onApply({
kind: 'insert-column',
at: Math.min(columnCount, column + 1),
label: translate('csv.newColumn', 'Column {{column}}', { column: columnCount + 1 })
})
}
>
{translate('csv.insertColumnAfter', 'Insert column after')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!selection || column === 0}
onSelect={() => onApply({ kind: 'move-column', from: column, to: column - 1 })}
>
{translate('csv.moveColumnLeft', 'Move column left')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!selection || column >= columnCount - 1}
onSelect={() => onApply({ kind: 'move-column', from: column, to: column + 1 })}
>
{translate('csv.moveColumnRight', 'Move column right')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!selection}
onSelect={() => onApply({ kind: 'delete-columns', columns: deleteColumns() })}
>
{translate('csv.deleteColumns', 'Delete selected columns')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" disabled={!columnCount}>
{translate('csv.sort', 'Sort')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => onSort({ column, direction: 'ascending' })}>
{translate('csv.sortAscending', 'Sort selected column ascending')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSort({ column, direction: 'descending' })}>
{translate('csv.sortDescending', 'Sort selected column descending')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!sort} onSelect={() => onSort(null)}>
{translate('csv.originalOrder', 'Original row order')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canSave && (
<Button variant="outline" size="xs" disabled={saving} aria-busy={saving} onClick={onSave}>
{translate('csv.save', 'Save')}
</Button>
)}
{(filter || sort) && (
<span className="text-xs text-muted-foreground">
{translate('csv.inspectionOnly', 'Inspection only · File row order unchanged')}
</span>
)}
</div>
)
}
@@ -0,0 +1,365 @@
// @vitest-environment happy-dom
import { useState } from 'react'
import { renderHook, cleanup, fireEvent, render, screen, act } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CsvViewer from './CsvViewer'
import { useCsvTableEditor } from './useCsvTableEditor'
import { parseCsvTextDocument } from './csv-text-document'
import { flushPendingEditorChange, hasPendingEditorChange } from '../editor-pending-flush'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../editor-autosave'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
estimateSize,
paddingStart = 0
}: {
count: number
estimateSize: (index: number) => number
paddingStart?: number
}) => ({
getVirtualItems: () =>
Array.from({ length: Math.min(count, 20) }, (_, index) => ({
index,
key: index,
start: paddingStart + index * estimateSize(index),
size: estimateSize(index),
end: paddingStart + (index + 1) * estimateSize(index)
})),
getTotalSize: () => paddingStart + count * estimateSize(0),
resizeItem: vi.fn(),
measure: vi.fn(),
scrollToIndex: vi.fn()
})
}))
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
localStorage.clear()
Reflect.deleteProperty(window, 'api')
})
function setup(source = '\ufeffname,value\r\nB,2\r\nA,1') {
const changes = vi.fn()
const save = vi.fn().mockResolvedValue(true)
function Editor() {
const [content, setContent] = useState(source)
const [dirty, setDirty] = useState(false)
return (
<>
<CsvViewer
content={content}
filePath="test.csv"
fileId="csv-edit-test"
isDirty={dirty}
onDirtyStateHint={setDirty}
onContentChange={(next) => {
changes(next)
setContent(next)
setDirty(next !== source)
}}
onSave={save}
/>
<output data-testid="source">{content}</output>
<output data-testid="dirty">{String(dirty)}</output>
</>
)
}
return { ...render(<Editor />), changes, save }
}
function edit(value: string, next: string): HTMLElement {
fireEvent.doubleClick(screen.getByRole('gridcell', { name: value }))
const input = screen.getByRole('textbox', { name: /Edit row/ })
fireEvent.change(input, { target: { value: next } })
return input
}
describe('editable CSV table', () => {
it('commits escaped multiline values while preserving BOM, endings and source order', () => {
const { changes } = setup()
const input = edit('B', 'x,"quoted"\nnext')
expect(screen.getByTestId('dirty').textContent).toBe('true')
fireEvent.keyDown(input, { key: 'Enter' })
expect(changes).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('source').textContent).toBe(
'\ufeffname,value\r\n"x,""quoted""\nnext",2\r\nA,1'
)
})
it('Escape cancels before the following blur can commit, preserving earlier dirty state', () => {
const { changes } = setup()
fireEvent.keyDown(edit('B', 'discarded'), { key: 'Escape' })
expect(changes).not.toHaveBeenCalled()
expect(screen.getByTestId('dirty').textContent).toBe('false')
fireEvent.keyDown(edit('B', 'kept'), { key: 'Enter' })
fireEvent.keyDown(edit('A', 'discarded too'), { key: 'Escape' })
expect(changes).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('dirty').textContent).toBe('true')
})
it('flushes the active input into the draft for global Save and hot exit', () => {
const { changes } = setup()
edit('B', 'pending')
act(() => flushPendingEditorChange('csv-edit-test'))
expect(changes).toHaveBeenCalledWith('\ufeffname,value\r\npending,2\r\nA,1')
})
it('retains pending input when switching away from the table', () => {
const { changes, unmount } = setup()
edit('B', 'switching')
unmount()
expect(changes).toHaveBeenCalledWith('\ufeffname,value\r\nswitching,2\r\nA,1')
})
it('saves the current cell value and reports a failed write without dropping the draft', async () => {
const { save } = setup()
save.mockResolvedValue(false)
fireEvent.keyDown(edit('B', 'saved value'), { key: 's', ctrlKey: true, metaKey: true })
expect(save).toHaveBeenCalledWith('\ufeffname,value\r\nsaved value,2\r\nA,1')
expect((await screen.findByRole('alert')).textContent).toContain(
'Your edits remain in the draft'
)
expect(screen.getByTestId('source').textContent).toContain('saved value')
})
it('pastes atomically into selected cells and does not partially apply an oversized rectangle', () => {
const { changes } = setup()
const cell = screen.getByRole('gridcell', { name: 'B' })
fireEvent.pointerDown(cell, { button: 0 })
const grid = screen.getByRole('grid')
fireEvent.paste(grid, { clipboardData: { getData: () => 'new\t3\nother\t4' } })
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nnew,3\r\nother,4')
fireEvent.paste(grid, { clipboardData: { getData: () => 'one\ttwo\tthree' } })
expect(changes).toHaveBeenCalledTimes(1)
expect(screen.getByRole('alert').textContent).toContain('Paste exceeds the table')
})
it('edits a filtered row in its original source position', () => {
setup()
fireEvent.change(screen.getByRole('textbox', { name: 'Filter rows' }), {
target: { value: 'A' }
})
fireEvent.keyDown(edit('1', 'updated'), { key: 'Enter' })
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,updated')
})
it('keeps the chosen table delimiter when an edit would change the Auto guess', () => {
setup('first;second\nvalue;two')
fireEvent.doubleClick(screen.getByRole('columnheader', { name: 'first' }))
const input = screen.getByRole('textbox', { name: /Edit row/ })
fireEvent.change(input, { target: { value: 'tabs\there\ttoo' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getByRole('grid').getAttribute('aria-colcount')).toBe('3')
expect(screen.getByRole('columnheader', { name: 'tabs\there\ttoo' })).toBeTruthy()
expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)')
})
it('cancels only the new cell after an earlier save completes', () => {
setup()
fireEvent.keyDown(edit('B', 'saved base'), { key: 'Enter' })
const source = screen.getByTestId('source').textContent
const input = edit('A', 'new pending cell')
expect(hasPendingEditorChange('csv-edit-test')).toBe(true)
act(() =>
window.dispatchEvent(
new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, {
detail: { fileId: 'csv-edit-test', content: source }
})
)
)
fireEvent.keyDown(input, { key: 'Escape' })
expect(hasPendingEditorChange('csv-edit-test')).toBe(false)
expect(screen.getByTestId('dirty').textContent).toBe('false')
expect(screen.getByTestId('source').textContent).toBe(source)
})
it('keeps a rejected over-limit edit visible and recoverable on view detachment', () => {
const { changes, unmount } = setup()
const value = 'x'.repeat(1024 * 1024 + 1)
fireEvent.keyDown(edit('B', value), { key: 'Enter' })
expect(screen.getByRole('alert').textContent).toContain('record is too large')
expect(changes).not.toHaveBeenCalled()
const retained = screen.getByRole('textbox', { name: /Edit row/ })
expect(retained instanceof HTMLTextAreaElement && retained.value).toBe(value)
unmount()
expect(changes).toHaveBeenCalledWith(`\ufeffname,value\r\n${value},2\r\nA,1`)
})
it('blocks table mutations after a rejected pending edit and preserves its recovery draft', () => {
const { changes, unmount } = setup()
const value = 'x'.repeat(1024 * 1024 + 1)
fireEvent.keyDown(edit('B', value), { key: 'Enter' })
fireEvent.keyDown(screen.getByRole('button', { name: 'Rows' }), {
key: 'ArrowDown'
})
fireEvent.click(screen.getByRole('menuitem', { name: 'Insert row below' }))
expect(changes).not.toHaveBeenCalled()
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,1')
unmount()
expect(changes).toHaveBeenCalledWith(`\ufeffname,value\r\n${value},2\r\nA,1`)
})
it('applies a pending cell and a subsequent structural mutation atomically', () => {
const changes = vi.fn()
const { result } = renderHook(() =>
useCsvTableEditor({
document: parseCsvTextDocument('name,value\nB,2', ','),
inspectionRows: null,
onContentChange: changes,
onStructureChange: vi.fn()
})
)
act(() => result.current.edit(1, 0))
act(() => result.current.interaction?.change('pending'))
act(() =>
expect(result.current.apply({ kind: 'insert-column', at: 1, label: 'new' })).toBe(true)
)
expect(changes).toHaveBeenCalledExactlyOnceWith('name,new,value\npending,,2')
expect(result.current.interaction?.editing).toBeNull()
})
it('leaves a read-only table without mutation controls or an input', () => {
render(<CsvViewer content={'a,b\nx,y'} filePath="readonly.csv" />)
fireEvent.doubleClick(screen.getByRole('cell', { name: 'x' }))
expect(screen.queryByRole('grid')).toBeNull()
expect(screen.queryByRole('textbox', { name: /Edit row/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
})
it('rejects an asynchronous paste when the selection changes while reading', async () => {
let resolveRead: (value: string) => void = () => {
throw new Error('read not initialized')
}
const pending = new Promise<string>((resolve) => {
resolveRead = resolve
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { readClipboardText: () => pending } }
})
const { changes } = setup()
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'B' }), { button: 0 })
fireEvent.click(screen.getByRole('button', { name: 'Paste' }))
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'A' }), { button: 0 })
await act(async () => {
resolveRead('stale paste')
await pending
})
expect(changes).not.toHaveBeenCalled()
expect(screen.getByRole('alert').textContent).toContain('CSV changed before paste completed')
})
it('rejects a delayed paste if typing starts before the clipboard read completes', async () => {
let resolveRead: (value: string) => void = () => {}
const pending = new Promise<string>((resolve) => {
resolveRead = resolve
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { readClipboardText: () => pending } }
})
const { changes } = setup()
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'B' }), { button: 0 })
fireEvent.click(screen.getByRole('button', { name: 'Paste' }))
edit('B', 'new pending input')
await act(async () => {
resolveRead('old clipboard')
await pending
})
expect(changes).not.toHaveBeenCalled()
expect(screen.getByRole('alert').textContent).toContain('CSV changed before paste completed')
const retained = screen.getByRole('textbox', { name: /Edit row/ })
expect(retained instanceof HTMLTextAreaElement && retained.value).toBe('new pending input')
})
it('routes menu paste only to the focused grid when two file panes are mounted', async () => {
const first = vi.fn()
const second = vi.fn()
const read = vi.fn().mockResolvedValue('pasted')
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { readClipboardText: read } }
})
render(
<>
<CsvViewer content={'header\nfirst'} filePath="first.csv" onContentChange={first} />
<CsvViewer content={'header\nsecond'} filePath="second.csv" onContentChange={second} />
</>
)
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 })
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'second' }), { button: 0 })
await act(async () => {
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
await Promise.resolve()
})
expect(read).toHaveBeenCalledTimes(1)
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledWith('header\npasted')
})
it('cancels delayed app-menu paste when focus moves to another file pane', async () => {
const first = vi.fn()
const second = vi.fn()
let resolveRead: (value: string) => void = () => {}
const pending = new Promise<string>((resolve) => {
resolveRead = resolve
})
const read = vi.fn(() => pending)
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { readClipboardText: read } }
})
render(
<>
<CsvViewer content={'header\nfirst'} filePath="first.csv" onContentChange={first} />
<CsvViewer content={'header\nsecond'} filePath="second.csv" onContentChange={second} />
</>
)
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 })
act(() => {
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
})
expect(read).toHaveBeenCalledTimes(1)
act(() => {
screen.getAllByRole('grid')[1]?.focus()
})
await act(async () => {
resolveRead('stale paste')
await pending
})
expect(first).not.toHaveBeenCalled()
expect(second).not.toHaveBeenCalled()
})
it('flushes an edited pane even when another pane of the same file registered later', () => {
const first = vi.fn()
const second = vi.fn()
render(
<>
<CsvViewer
content={'header\nfirst'}
filePath="same.csv"
fileId="same-file"
onContentChange={first}
/>
<CsvViewer
content={'header\nfirst'}
filePath="same.csv"
fileId="same-file"
onContentChange={second}
/>
</>
)
fireEvent.doubleClick(screen.getAllByRole('gridcell', { name: 'first' })[0]!)
fireEvent.change(screen.getByRole('textbox', { name: /Edit row/ }), {
target: { value: 'pending first pane' }
})
expect(hasPendingEditorChange('same-file')).toBe(true)
act(() => flushPendingEditorChange('same-file'))
expect(first).toHaveBeenCalledWith('header\npending first pane')
expect(second).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,224 @@
import { useCallback, useMemo, useState } from 'react'
import { detectCsvDelimiter } from './csv-parse'
import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker'
import { CsvGrid } from './CsvGrid'
import { translate } from '@/i18n/i18n'
import { openCsvHttpLink } from './csv-link-routing'
import { parseCsvTextDocument } from './csv-text-document'
import { csvInspectionRows, csvSourceRow, type CsvInspectionSort } from './csv-inspection'
import { useCsvTableEditor } from './useCsvTableEditor'
import { CsvTableToolbar } from './CsvTableToolbar'
import { remapCsvColumnWidths } from './csv-column-width-preferences'
import { useCsvColumnWidths } from './useCsvColumnWidths'
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
export default function CsvViewer({
content,
filePath,
worktreeId,
runtimeEnvironmentId,
preferenceKey,
onContentChange,
onSave,
fileId,
isDirty,
onDirtyStateHint
}: {
content: string
filePath: string
worktreeId?: string
runtimeEnvironmentId?: string | null
preferenceKey?: string
fileId?: string
isDirty?: boolean
onDirtyStateHint?: (dirty: boolean) => void
onContentChange?: (content: string) => void
onSave?: (content: string) => Promise<boolean>
}): React.JSX.Element {
const [delimiterChoice, setDelimiterChoice] = useState<CsvDelimiterChoice>('auto')
const detectedDelimiter = useMemo(
() => detectCsvDelimiter(filePath, content),
[filePath, content]
)
const delimiter = csvChosenDelimiter(delimiterChoice, detectedDelimiter)
const result = useMemo(() => {
try {
return {
parsed: parseCsvTextDocument(content, delimiter),
error: null
}
} catch (error) {
return { parsed: null, error: error instanceof Error ? error.message : String(error) }
}
}, [content, delimiter])
const [filter, setFilter] = useState('')
const [sort, setSort] = useState<CsvInspectionSort | null>(null)
const storedKey = JSON.stringify([
preferenceKey ??
buildFileViewPreferenceKey({
worktreeId: worktreeId ?? '',
runtimeEnvironmentId,
filePath
}),
delimiter
])
const { widths: columnWidths, changeWidths } = useCsvColumnWidths(storedKey)
const document = result.parsed
const effectiveSort = sort && sort.column < (document?.columnCount ?? 0) ? sort : null
const inspectionRows = useMemo(
() => (document ? csvInspectionRows(document.rows, filter, effectiveSort) : null),
[document, filter, effectiveSort]
)
const editor = useCsvTableEditor({
document,
fileId,
isDirty,
onDirtyStateHint,
inspectionRows,
onContentChange,
onSave,
onStructureChange: (mutation) => {
if (delimiterChoice === 'auto') {
setDelimiterChoice(delimiter === ',' ? 'comma' : delimiter === ';' ? 'semicolon' : 'tab')
}
const widths = remapCsvColumnWidths(columnWidths, document?.columnCount ?? 0, mutation)
if (widths !== columnWidths) {
changeWidths(widths)
}
if (
mutation.kind === 'delete-columns' ||
mutation.kind === 'insert-column' ||
mutation.kind === 'move-column'
) {
setSort(null)
}
}
})
const header = document?.rows[0] ?? []
const rowCount = inspectionRows?.length ?? Math.max(0, (document?.rows.length ?? 0) - 1)
const sample = useMemo(() => document?.rows.slice(1, 201) ?? [], [document])
const getRow = useCallback(
(index: number) => document?.rows[csvSourceRow(index + 1, inspectionRows)],
[document, inspectionRows]
)
return (
<div ref={editor.setRootRef} className="flex h-full min-h-0 flex-col">
{onContentChange && (
<CsvTableToolbar
selection={editor.selection}
getSelectedRows={editor.selectedRows}
sourceRow={csvSourceRow(editor.selection?.focus.row ?? 0, inspectionRows)}
sourceRowCount={document?.rows.length ?? 0}
columnCount={document?.columnCount ?? 0}
filter={filter}
sort={effectiveSort}
saving={editor.saving}
canEdit={!result.error}
canSave={Boolean(onSave)}
onFilter={(next) => {
if (editor.interaction?.commit() !== false) {
setFilter(next)
}
}}
onSort={(next) => {
if (editor.interaction?.commit() !== false) {
setSort(next)
}
}}
onApply={editor.apply}
onEdit={() =>
editor.edit(editor.selection?.focus.row ?? 0, editor.selection?.focus.column ?? 0)
}
onSave={() => void editor.save()}
onCopy={() => void editor.copy()}
onPaste={() => void editor.paste()}
/>
)}
{editor.error && (
<div role="alert" className="border-b border-border px-3 py-2 text-sm text-destructive">
{editor.error}
</div>
)}
{result.error ? (
<div
role="alert"
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
>
{result.error}
</div>
) : result.parsed?.rows.length ? (
<CsvGrid
key={delimiter}
header={header}
rowCount={rowCount}
columnCount={result.parsed.columnCount}
interaction={editor.interaction}
columnWidths={columnWidths}
onColumnWidthsChange={changeWidths}
sampleRows={sample}
getRow={getRow}
onOpenUrl={(url, event) =>
openCsvHttpLink(url, event, { filePath, worktreeId, runtimeEnvironmentId })
}
/>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{translate('auto.components.editor.CsvViewer.a233d55b77', 'Empty file')}
</div>
)}
<CsvFooter
rowCount={rowCount}
columnCount={result.parsed?.columnCount ?? 0}
delimiterChoice={delimiterChoice}
detectedDelimiter={detectedDelimiter}
onDelimiterChange={(next) => {
if (editor.interaction?.commit() !== false) {
setDelimiterChoice(next)
}
}}
/>
</div>
)
}
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 (
<div className="flex flex-wrap items-center gap-4 border-t border-border/60 px-3 py-1 text-xs text-muted-foreground">
<span>
{rowCount.toLocaleString()}{' '}
{translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')}
</span>
<span>
{columnCount.toLocaleString()}{' '}
{translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')}
</span>
<CsvDelimiterPicker
value={delimiterChoice}
detectedDelimiter={detectedDelimiter}
onChange={onDelimiterChange}
/>
</div>
)
}
@@ -0,0 +1,90 @@
import {
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
assertClipboardTextWithinLimit,
assertClipboardTextWriteWithinLimit
} from '../../../../../shared/clipboard-text'
import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index'
import { measureUtf8ByteLength } from '../../../../../shared/utf8-byte-limits'
import { parseCsv } from './csv-parse'
import { serializeCsvRecord } from './csv-serialization'
import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection'
import { csvSourceRow } from './csv-inspection'
import type { CsvCellEdit } from './csv-text-document'
const CLIPBOARD_CELLS = 1024 * 1024
export function copyCsvSelection(
selection: CsvCellSelection,
rows: readonly string[][],
inspectionRows: readonly number[] | null
): string {
const bounds = csvSelectionBounds(selection)
const records: string[] = []
let cells = 0
let bytes = 0
for (let row = bounds.firstRow; row <= bounds.lastRow; row += 1) {
const source = rows[csvSourceRow(row, inspectionRows)]
if (!source) {
throw new Error('The selected CSV row no longer exists.')
}
const values: string[] = []
for (let column = bounds.firstColumn; column <= bounds.lastColumn; column += 1) {
if (++cells > CLIPBOARD_CELLS) {
throw new Error('Select fewer cells to copy at once.')
}
values.push(source[column] ?? '')
}
const record = serializeCsvRecord(values, '\t')
bytes +=
measureUtf8ByteLength(record, { stopAfterBytes: CLIPBOARD_TEXT_WRITE_MAX_BYTES }).byteLength +
(records.length ? 1 : 0)
if (bytes > CLIPBOARD_TEXT_WRITE_MAX_BYTES) {
throw new Error('Selected cells are too large to copy safely.')
}
records.push(record)
}
return assertClipboardTextWriteWithinLimit(records.join('\n'))
}
export function pasteCsvSelection(
text: string,
selection: CsvCellSelection,
rows: readonly string[][],
columnCount: number,
inspectionRows: readonly number[] | null
): CsvCellEdit[] {
assertClipboardTextWithinLimit(text)
const pasted =
text === ''
? [['']]
: parseCsv(text, '\t', {
maxCells: CLIPBOARD_CELLS,
maxColumns: CSV_MAX_COLUMNS,
maxRecordLength: CSV_RECORD_BYTES,
stripBom: false
}).rows
const bounds = csvSelectionBounds(selection)
const fill = pasted.length === 1 && pasted[0]?.length === 1
const height = fill ? bounds.lastRow - bounds.firstRow + 1 : pasted.length
const width = fill
? bounds.lastColumn - bounds.firstColumn + 1
: pasted.reduce((maximum, row) => Math.max(maximum, row.length), 0)
const viewRowCount = inspectionRows ? inspectionRows.length + 1 : rows.length
if (bounds.firstRow + height > viewRowCount || bounds.firstColumn + width > columnCount) {
throw new Error('Paste exceeds the table. Add rows or columns first.')
}
if (height * width > CLIPBOARD_CELLS) {
throw new Error('Select fewer cells to paste at once.')
}
const edits: CsvCellEdit[] = []
for (let row = 0; row < height; row += 1) {
for (let column = 0; column < width; column += 1) {
edits.push({
row: csvSourceRow(bounds.firstRow + row, inspectionRows),
column: bounds.firstColumn + column,
value: fill ? pasted[0]![0]! : (pasted[row]?.[column] ?? '')
})
}
}
return edits
}
@@ -0,0 +1,64 @@
export type CsvCellPosition = { row: number; column: number }
export type CsvCellSelection = { anchor: CsvCellPosition; focus: CsvCellPosition }
export function remapCsvSelectionAfterColumnMove(
selection: CsvCellSelection | null,
from: number,
to: number
): CsvCellSelection | null {
if (!selection) {
return null
}
const moved = (column: number): number => {
if (column === from) {
return to
}
if (from < to && column > from && column <= to) {
return column - 1
}
if (from > to && column >= to && column < from) {
return column + 1
}
return column
}
return {
anchor: { ...selection.anchor, column: moved(selection.anchor.column) },
focus: { ...selection.focus, column: moved(selection.focus.column) }
}
}
export function csvSelectionBounds(selection: CsvCellSelection) {
return {
firstRow: Math.min(selection.anchor.row, selection.focus.row),
lastRow: Math.max(selection.anchor.row, selection.focus.row),
firstColumn: Math.min(selection.anchor.column, selection.focus.column),
lastColumn: Math.max(selection.anchor.column, selection.focus.column)
}
}
export function csvCellIsSelected(
selection: CsvCellSelection | null,
row: number,
column: number
): boolean {
if (!selection) {
return false
}
const bounds = csvSelectionBounds(selection)
return (
row >= bounds.firstRow &&
row <= bounds.lastRow &&
column >= bounds.firstColumn &&
column <= bounds.lastColumn
)
}
export function moveCsvSelection(
selection: CsvCellSelection | null,
row: number,
column: number,
extend: boolean
): CsvCellSelection {
const focus = { row, column }
return { anchor: extend && selection ? selection.anchor : focus, focus }
}
@@ -0,0 +1,2 @@
export const CSV_MIN_COLUMN_WIDTH = 48
export const CSV_MAX_COLUMN_WIDTH = 1200
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import {
CSV_COLUMN_WIDTHS_STORAGE_KEY,
readCsvColumnWidths,
writeCsvColumnWidths,
remapCsvColumnWidths
} from './csv-column-width-preferences'
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
it('persists widths through remounts and isolates the same path by owner', () => {
const owner = { worktreeId: 'folder:a', filePath: '/repo/a.csv' }
const local = buildFileViewPreferenceKey(owner)
const paired = buildFileViewPreferenceKey({ ...owner, runtimeEnvironmentId: 'paired' })
const ssh = buildFileViewPreferenceKey({ ...owner, externalSshTargetId: 'ssh' })
writeCsvColumnWidths(local, { 0: 180, 2: 300 })
expect(readCsvColumnWidths(local)).toEqual({ 0: 180, 2: 300 })
expect(readCsvColumnWidths(paired)).toEqual({})
expect(readCsvColumnWidths(ssh)).toEqual({})
writeCsvColumnWidths(local, {})
expect(readCsvColumnWidths(local)).toEqual({})
})
it('ignores corrupted indexes, malformed values and unsupported widths', () => {
localStorage.setItem(
CSV_COLUMN_WIDTHS_STORAGE_KEY,
JSON.stringify({
file: {
'0': 160,
'1': '80',
'-1': 160,
'02': 160,
'2.5': 160,
'4096': 160,
'3': 20,
'4': 1300,
'5': null
}
})
)
expect(readCsvColumnWidths('file')).toEqual({ 0: 160 })
localStorage.setItem(CSV_COLUMN_WIDTHS_STORAGE_KEY, 'invalid json')
expect(readCsvColumnWidths('file')).toEqual({})
})
it('bounds retained file preferences and tolerates storage failure', () => {
for (let index = 0; index <= 100; index += 1) {
writeCsvColumnWidths(`file-${index}`, { 0: 160 })
}
expect(readCsvColumnWidths('file-0')).toEqual({})
expect(readCsvColumnWidths('file-100')).toEqual({ 0: 160 })
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
expect(() => writeCsvColumnWidths('file', { 0: 180 })).not.toThrow()
})
it('moves widths with column identity through insertion, deletion and reordering', () => {
const widths = { 0: 180, 2: 300 }
expect(remapCsvColumnWidths(widths, 3, { kind: 'insert-column', at: 1, label: '' })).toEqual({
0: 180,
3: 300
})
expect(remapCsvColumnWidths(widths, 3, { kind: 'delete-columns', columns: [1] })).toEqual({
0: 180,
1: 300
})
expect(remapCsvColumnWidths(widths, 3, { kind: 'move-column', from: 2, to: 0 })).toEqual({
0: 300,
1: 180
})
})
@@ -0,0 +1,62 @@
import { CSV_MAX_COLUMNS } from './csv-byte-index'
import { CSV_MAX_COLUMN_WIDTH, CSV_MIN_COLUMN_WIDTH } from './csv-column-width-limits'
import { readFileViewPreference, writeFileViewPreference } from '../file-view-preference-storage'
import type { CsvTableMutation } from './csv-text-document'
export const CSV_COLUMN_WIDTHS_STORAGE_KEY = 'orca.csv.column-widths.v1'
export type CsvColumnWidths = Record<number, number>
export function readCsvColumnWidths(fileKey: string): CsvColumnWidths {
const stored = readFileViewPreference(CSV_COLUMN_WIDTHS_STORAGE_KEY, fileKey)
const result: CsvColumnWidths = {}
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
return result
}
for (const [key, width] of Object.entries(stored)) {
const index = Number(key)
if (
Number.isInteger(index) &&
String(index) === key &&
index >= 0 &&
index < CSV_MAX_COLUMNS &&
typeof width === 'number' &&
Number.isFinite(width) &&
width >= CSV_MIN_COLUMN_WIDTH &&
width <= CSV_MAX_COLUMN_WIDTH
) {
result[index] = width
}
}
return result
}
export function writeCsvColumnWidths(fileKey: string, widths: CsvColumnWidths): void {
writeFileViewPreference(CSV_COLUMN_WIDTHS_STORAGE_KEY, fileKey, widths)
}
export function remapCsvColumnWidths(
widths: CsvColumnWidths,
count: number,
mutation: CsvTableMutation
): CsvColumnWidths {
const columns = Array.from({ length: count }, (_, index) => index)
if (mutation.kind === 'insert-column') {
columns.splice(mutation.at, 0, -1)
} else if (mutation.kind === 'delete-columns') {
const deleted = new Set(mutation.columns)
return Object.fromEntries(
columns
.filter((column) => !deleted.has(column))
.flatMap((column, index) => (widths[column] === undefined ? [] : [[index, widths[column]]]))
)
} else if (mutation.kind === 'move-column') {
columns.splice(mutation.to, 0, columns.splice(mutation.from, 1)[0]!)
} else {
return widths
}
return Object.fromEntries(
columns.flatMap((column, index) =>
widths[column] === undefined ? [] : [[index, widths[column]]]
)
)
}
@@ -4,10 +4,12 @@ import {
statRuntimeReadTarget,
type RuntimeFileSnapshot
} from '@/runtime/runtime-file-range-client'
import type { FileContent } from './editor-panel-content-types'
import type { FileContent } from '../editor-panel-content-types'
import { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits'
export { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits'
export type CsvFilePreview = { readArgs: RuntimeFileReadArgs; snapshot: RuntimeFileSnapshot }
export const CSV_PAGED_PREVIEW_BYTES = 1024 * 1024
export async function readEditorCsvFileContent(
args: RuntimeFileReadArgs,
@@ -0,0 +1 @@
export const CSV_PAGED_PREVIEW_BYTES = 1024 * 1024
@@ -0,0 +1,26 @@
import type { ClipboardEvent, KeyboardEvent } from 'react'
import type { CsvCellPosition, CsvCellSelection } from './csv-cell-selection'
export type CsvCellEditSession = {
position: CsvCellPosition
value: string
sourceRow: number
source: string
wasDirty: boolean
}
export type CsvGridInteraction = {
selection: CsvCellSelection | null
focusVersion: number
ownerId: string
editing: { position: CsvCellPosition; value: string } | null
select: (row: number, column: number, extend: boolean) => boolean
edit: (row: number, column: number) => void
change: (value: string) => void
commit: () => boolean
cancel: () => void
save: () => void
keyDown: (event: KeyboardEvent<HTMLDivElement>) => void
copy: (event: ClipboardEvent<HTMLDivElement>) => void
paste: (event: ClipboardEvent<HTMLDivElement>) => void
}
@@ -0,0 +1,115 @@
import type { KeyboardEvent } from 'react'
import type { CsvCellSelection } from './csv-cell-selection'
export function csvGridKeyDown(
event: KeyboardEvent<HTMLDivElement>,
{
selection,
columns,
count,
copy,
paste,
save,
edit,
clear,
cancel,
select,
selectAll
}: {
selection: CsvCellSelection | null
columns: number
count: number
copy: () => Promise<void>
paste: () => Promise<void>
save: () => Promise<void>
edit: (row: number, column: number) => void
clear: () => void
cancel: () => void
select: (row: number, column: number, extend: boolean) => boolean
selectAll: (selection: CsvCellSelection) => void
}
): void {
if (
event.target !== event.currentTarget ||
event.nativeEvent.isComposing ||
event.defaultPrevented
) {
return
}
const modifier = navigator.userAgent.includes('Mac') ? event.metaKey : event.ctrlKey
if (modifier && ['c', 'v', 's', 'a'].includes(event.key.toLowerCase())) {
event.preventDefault()
if (event.key.toLowerCase() === 'c') {
void copy()
}
if (event.key.toLowerCase() === 'v') {
void paste()
}
if (event.key.toLowerCase() === 's') {
void save()
}
if (event.key.toLowerCase() === 'a' && columns) {
selectAll({
anchor: { row: 0, column: 0 },
focus: { row: count, column: columns - 1 }
})
}
return
}
const active = selection?.focus ?? { row: 0, column: 0 }
if (event.key === 'Enter' || event.key === 'F2') {
event.preventDefault()
edit(active.row, active.column)
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
clear()
} else if (event.key === 'Escape') {
cancel()
} else if (
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab'].includes(event.key)
) {
event.preventDefault()
let row = active.row
let column = active.column
if (event.key === 'ArrowUp') {
row -= 1
}
if (event.key === 'ArrowDown') {
row += 1
}
if (event.key === 'ArrowLeft') {
column -= 1
}
if (event.key === 'ArrowRight') {
column += 1
}
if (event.key === 'Home') {
column = 0
if (modifier) {
row = 0
}
}
if (event.key === 'End') {
column = columns - 1
if (modifier) {
row = count
}
}
if (event.key === 'Tab') {
column += event.shiftKey ? -1 : 1
if (column >= columns) {
column = 0
row += 1
}
if (column < 0) {
column = columns - 1
row -= 1
}
}
select(
Math.max(0, Math.min(count, row)),
Math.max(0, Math.min(columns - 1, column)),
event.shiftKey && event.key !== 'Tab'
)
}
}
@@ -0,0 +1,32 @@
export type CsvInspectionSort = { column: number; direction: 'ascending' | 'descending' }
export function csvInspectionRows(
rows: readonly string[][],
filter: string,
sort: CsvInspectionSort | null
): number[] | null {
if (!filter && !sort) {
return null
}
const query = filter.toLocaleLowerCase()
const result: number[] = []
for (let row = 1; row < rows.length; row += 1) {
if (!query || rows[row]!.some((cell) => cell.toLocaleLowerCase().includes(query))) {
result.push(row)
}
}
if (sort) {
const collator = new Intl.Collator(undefined, { numeric: true })
const direction = sort.direction === 'ascending' ? 1 : -1
result.sort(
(a, b) =>
direction * collator.compare(rows[a]?.[sort.column] ?? '', rows[b]?.[sort.column] ?? '') ||
a - b
)
}
return result
}
export function csvSourceRow(viewRow: number, inspectionRows: readonly number[] | null): number {
return viewRow === 0 ? 0 : inspectionRows ? (inspectionRows[viewRow - 1] ?? -1) : viewRow
}
@@ -1,6 +1,6 @@
import { getConnectionIdForFile } from '@/lib/connection-context'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { resolveMarkdownPreviewHttpOpenOptions } from './markdown-preview-links'
import { resolveMarkdownPreviewHttpOpenOptions } from '../markdown-preview-links'
export function openCsvHttpLink(
url: string,
@@ -1,6 +1,6 @@
import { MAX_FILE_RANGE_READ_BYTES } from '../../../../shared/file-range-read'
import { MAX_FILE_RANGE_READ_BYTES } from '../../../../../shared/file-range-read'
import { readRuntimeFileRange, statRuntimeReadTarget } from '@/runtime/runtime-file-range-client'
import type { CsvFilePreview } from './editor-csv-file-content'
import type { CsvFilePreview } from './csv-file-content'
import {
CSV_MAX_PAGE_BYTES,
csvPageForRow,
@@ -6,6 +6,9 @@ export type CsvParseLimits = {
maxColumns?: number
maxRecordLength?: number
stripBom?: boolean
retainRows?: boolean
onField?: (start: number, end: number) => void
onRecord?: (start: number, contentEnd: number, end: number) => void
}
export function parseCsv(
@@ -17,6 +20,7 @@ export function parseCsv(
let row: string[] = []
let start = limits.stripBom !== false && source.charCodeAt(0) === 0xfeff ? 1 : 0
let recordStart = start
let fieldStart = start
let parts: string[] = []
let fieldLength = 0
let inQuotes = false
@@ -40,9 +44,11 @@ export function parseCsv(
} else {
row.push(source.slice(start, end))
}
limits.onField?.(fieldStart, end)
parts = []
fieldLength = 0
start = end + 1
fieldStart = start
}
const pushRow = (end: number, next: number): void => {
if (end - recordStart > (limits.maxRecordLength ?? Infinity)) {
@@ -50,11 +56,15 @@ export function parseCsv(
}
pushField(end)
maxColumns = Math.max(maxColumns, row.length)
rows.push(row)
if (limits.retainRows !== false) {
rows.push(row)
}
limits.onRecord?.(recordStart, end, next)
row = []
hasContent = false
start = next
recordStart = next
fieldStart = next
}
for (let i = start; i < source.length; i += 1) {
const ch = source[i]
@@ -0,0 +1,127 @@
import { afterEach, expect, it, vi } from 'vitest'
import { createEditorSaveQueue } from '../editor-save-queue'
import { registerPendingEditorFlush } from '../editor-pending-flush'
import {
createEditorStore,
stubEditorWindowWithDisk
} from '../editor-autosave-controller-test-fixture'
afterEach(() => vi.unstubAllGlobals())
function setup() {
const disk = stubEditorWindowWithDisk()
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/data.csv',
relativePath: 'data.csv',
worktreeId: 'wt-1',
mode: 'edit',
language: 'plaintext'
})
const file = store.getState().openFiles[0]!
store.getState().setEditorDraft(file.id, 'header\nprevious')
store.getState().markFileDirty(file.id, true)
const queue = createEditorSaveQueue(store)
return { disk, store, file, queue }
}
it('autosave flushes a legacy editor and reads its updated draft before writing', async () => {
const { disk, store, file, queue } = setup()
const unregister = registerPendingEditorFlush(file.id, () =>
store.getState().setEditorDraft(file.id, 'header\nlatest')
)
try {
await queue.queueSave(file, 'header\nstale', 'autosave')
expect(disk.files.get(file.filePath)).toBe('header\nlatest')
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
} finally {
unregister()
queue.dispose()
}
})
it('a failed pending edit prevents the write and keeps the existing dirty draft', async () => {
const { disk, store, file, queue } = setup()
const unregister = registerPendingEditorFlush(file.id, () => {
throw new Error('invalid pending cell')
})
try {
await expect(queue.queueSave(file, 'fallback')).rejects.toThrow('invalid pending cell')
expect(disk.fs.writeFile).not.toHaveBeenCalled()
expect(store.getState().editorDrafts[file.id]).toBe('header\nprevious')
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
} finally {
unregister()
queue.dispose()
}
})
it('a suspended autosave does not flush a pending cell or write the file', async () => {
const { disk, store, file, queue } = setup()
store.getState().setExternalMutation(file.id, 'changed')
const flush = vi.fn()
const unregister = registerPendingEditorFlush(file.id, flush)
try {
await queue.queueSave(file, 'fallback', 'autosave')
expect(flush).not.toHaveBeenCalled()
expect(disk.fs.writeFile).not.toHaveBeenCalled()
} finally {
unregister()
queue.dispose()
}
})
it('keeps a newer pending cell dirty when an earlier remote save completes', async () => {
const { disk, store, file, queue } = setup()
let releaseWrite: () => void = () => {
throw new Error('write not started')
}
disk.fs.writeFile.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseWrite = resolve
})
)
let pending = false
const unregister = registerPendingEditorFlush(
file.id,
() => {},
() => pending
)
try {
const saving = queue.queueSave(file, 'fallback')
await vi.waitFor(() => expect(disk.fs.writeFile).toHaveBeenCalledTimes(1))
pending = true
releaseWrite()
await saving
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
expect(store.getState().editorDrafts[file.id]).toBe('header\nprevious')
} finally {
unregister()
queue.dispose()
}
})
it.each([false, true])(
'autosave leaves an active CSV input alone (changed: %s)',
async (changed) => {
const { disk, store, file, queue } = setup()
const flush = vi.fn(() => {
throw new Error('rejected cell')
})
const unregister = registerPendingEditorFlush(file.id, flush, () => changed)
try {
await queue.queueSave(file, 'fallback', 'autosave')
expect(flush).not.toHaveBeenCalled()
expect(disk.files.get(file.filePath)).toBe('header\nprevious')
expect(store.getState().openFiles[0]?.isDirty).toBe(changed)
if (changed) {
await queue.queueSave(file, 'fallback', 'autosave')
expect(disk.fs.writeFile).toHaveBeenCalledTimes(1)
}
} finally {
unregister()
queue.dispose()
}
}
)
@@ -1,8 +1,8 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { createEditorStore } from '@/store/slices/editor-slice-test-harness'
import { createEditorSaveQueue } from './editor-save-queue'
import { canAutoSaveOpenFile } from './editor-autosave'
import { createEditorSaveQueue } from '../editor-save-queue'
import { canAutoSaveOpenFile } from '../editor-autosave'
const { writeFile } = vi.hoisted(() => ({ writeFile: vi.fn() }))
vi.mock('@/runtime/runtime-file-client', () => ({ writeRuntimeFile: writeFile }))
@@ -0,0 +1,30 @@
export function serializeCsvCell(value: string, delimiter: string, quoted = false): string {
return quoted || /["\r\n]/.test(value) || value.includes(delimiter) || value.startsWith('\ufeff')
? `"${value.replaceAll('"', '""')}"`
: value
}
export function serializeCsvRecord(
values: readonly string[],
delimiter: string,
originalValues: readonly string[] = [],
originalTokens: readonly string[] = []
): string {
return values
.map((value, index) => {
const token = originalTokens[index]
const quoteSingleField = values.length === 1 && (value === '' || /[,;\t]/.test(value))
const validToken =
token !== undefined &&
(token.startsWith('"') ? /^"(?:[^"]|"")*"$/.test(token) : !/["\r\n]/.test(token))
if (
value === originalValues[index] &&
validToken &&
!(quoteSingleField && !token?.startsWith('"'))
) {
return token
}
return serializeCsvCell(value, delimiter, token?.startsWith('"') || quoteSingleField)
})
.join(delimiter)
}

Some files were not shown because too many files have changed in this diff Show More