From a393facef8be57ce12b92462cbb6a5d6695ae1fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:28:09 -0700 Subject: [PATCH 01/46] Keep the Peek References scrollbar visible (#7584) --- src/renderer/src/assets/main.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 08f39a8b61c..f028b641384 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1904,13 +1904,14 @@ text-underline-offset: 2px; } -/* Why: Monaco owns Peek References scrollables and auto-hides faded needed - vertical thumbs while the widget stays open; revalidate these classes on upgrade. */ +/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with + Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */ .monaco-editor .reference-zone-widget .monaco-scrollable-element > .scrollbar.vertical.invisible.fade { opacity: 1; + visibility: visible; pointer-events: auto; } From 99ae2dc90f5a3c2274a8905c013debae9f46d2cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:41:51 -0700 Subject: [PATCH 02/46] Show full workspace file tree on mobile (#7289) Co-authored-by: Orca --- .../scripts/mock-server-file-preview-data.ts | 29 ++ .../src/files/MobileFileExplorerPanel.test.ts | 227 +++++++++++++ mobile/src/files/MobileFileExplorerPanel.tsx | 303 +++++++++++------- .../files/directory-load-revisions.test.ts | 44 +++ mobile/src/files/directory-load-revisions.ts | 42 +++ mobile/src/files/file-tree.test.ts | 115 +++++-- mobile/src/files/file-tree.ts | 199 ++++++++---- mobile/src/files/mobile-file-explorer-row.tsx | 145 +++++++++ .../src/files/mobile-file-explorer-styles.ts | 31 ++ src/main/runtime/runtime-rpc.ts | 1 + 10 files changed, 925 insertions(+), 211 deletions(-) create mode 100644 mobile/src/files/MobileFileExplorerPanel.test.ts create mode 100644 mobile/src/files/directory-load-revisions.test.ts create mode 100644 mobile/src/files/directory-load-revisions.ts create mode 100644 mobile/src/files/mobile-file-explorer-row.tsx diff --git a/mobile/scripts/mock-server-file-preview-data.ts b/mobile/scripts/mock-server-file-preview-data.ts index a64c76eda3b..9bf43471df6 100644 --- a/mobile/scripts/mock-server-file-preview-data.ts +++ b/mobile/scripts/mock-server-file-preview-data.ts @@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [ { relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' } ] +function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> { + const prefix = relativePath ? `${relativePath}/` : '' + const children = new Map() + for (const file of MOCK_FILE_LIST) { + if (!file.relativePath.startsWith(prefix)) { + continue + } + const rest = file.relativePath.slice(prefix.length) + if (!rest) { + continue + } + const [name, ...descendants] = rest.split('/') + if (!name) { + continue + } + children.set(name, children.get(name) === true || descendants.length > 0) + } + return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) +} + export function handleMockFilePreviewRequest( request: RpcRequest, respond: Respond, @@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest( ) return true + case 'files.readDir': + respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? '')))) + return true + case 'files.read': { const relativePath = String(request.params?.relativePath ?? '') const content = MOCK_FILE_CONTENT[relativePath] diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts new file mode 100644 index 00000000000..b86e722f1dc --- /dev/null +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -0,0 +1,227 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' +import type { MobileDirEntry } from './file-tree' +import type { RpcResponse } from '../transport/types' + +type MockClient = { + sendRequest: ReturnType +} + +const mockTransport = vi.hoisted(() => ({ + client: null as MockClient | null, + connectionState: 'connected', + forceReconnect: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: (props: { + data: unknown[] + keyExtractor: (item: unknown, index: number) => string + renderItem: (info: { + item: unknown + index: number + separators: Record + }) => unknown + }) => + React.createElement( + 'FlatList', + props, + props.data.map((item, index) => + React.createElement( + 'FlatListItem', + { key: props.keyExtractor(item, index) }, + props.renderItem({ item, index, separators: {} }) + ) + ) + ), + Pressable: 'Pressable', + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView' +})) + +vi.mock('expo-router', () => ({ + useRouter: () => ({ + back: vi.fn(), + push: vi.fn() + }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + File: 'File', + FileText: 'FileText', + Folder: 'Folder', + Image: 'Image', + X: 'X' +})) + +vi.mock('../platform/haptics', () => ({ + triggerSelection: vi.fn() +})) + +vi.mock('../transport/client-context', () => ({ + useForceReconnect: () => mockTransport.forceReconnect, + useHostClient: () => ({ + client: mockTransport.client, + state: mockTransport.connectionState + }) +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function entry(name: string, isDirectory = false): MobileDirEntry { + return { name, isDirectory } +} + +function ok(result: MobileDirEntry[]): RpcResponse { + return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } } +} + +function createMockClient(entriesByPath: Record): MockClient { + return { + sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => { + return ok(entriesByPath[params.relativePath] ?? []) + }) + } +} + +async function renderExplorer(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('MobileFileExplorerPanel did not render') + } + return renderer +} + +async function updateExplorer(renderer: ReactTestRenderer): Promise { + await act(async () => { + renderer.update( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) +} + +async function pressByLabel( + renderer: ReactTestRenderer, + accessibilityLabel: string +): Promise { + const pressable = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === accessibilityLabel) + if (!pressable) { + throw new Error(`Unable to find pressable: ${accessibilityLabel}`) + } + await act(async () => { + pressable.props.onPress() + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('MobileFileExplorerPanel', () => { + afterEach(() => { + vi.restoreAllMocks() + mockTransport.client = null + mockTransport.connectionState = 'connected' + mockTransport.forceReconnect = vi.fn() + }) + + it('loads directories lazily and reuses cached children when reopened', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')], + src: [entry('app.ts'), entry('components', true)] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: '' + }) + + await pressByLabel(renderer, 'Open folder src') + await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2)) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: 'src' + }) + expect(renderedText(renderer)).toContain('app.ts') + + await pressByLabel(renderer, 'Open folder src') + await pressByLabel(renderer, 'Open folder src') + + expect(client.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps the loaded tree visible during a transient disconnect', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('src') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('Waiting for desktop...') + }) +}) diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 8192eb90020..b777c1379e5 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, FlatList, @@ -9,34 +9,28 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { - ChevronDown, - ChevronLeft, - ChevronRight, - File, - FileText, - Folder, - Image as ImageIcon, - X -} from 'lucide-react-native' +import { ChevronLeft, X } from 'lucide-react-native' import { useHostClient, useForceReconnect } from '../transport/client-context' import { getWorktreeLabel } from '../session/worktree-label' import { - buildTree, - flattenTree, - isMarkdownPath, - type FilesListResult, - type MobileFileEntry, - type TreeNode + flattenDirectoryCache, + getDirectoryCacheState, + type DirectoryCache, + type FileExplorerRow, + type MobileDirEntry } from './file-tree' import type { RpcSuccess } from '../transport/types' -import { triggerSelection } from '../platform/haptics' -import { colors, spacing } from '../theme/mobile-theme' -import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { colors } from '../theme/mobile-theme' import { - canPreviewMobileFileRow, - navigateToMobileFilePreview -} from './mobile-file-preview-navigation' + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { MobileFileExplorerRow } from './mobile-file-explorer-row' +import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' export function MobileFileExplorerPanel(props: { hostId: string @@ -49,58 +43,173 @@ export function MobileFileExplorerPanel(props: { const router = useRouter() const { client, state: connState } = useHostClient(hostId) const forceReconnect = useForceReconnect() - const [files, setFiles] = useState([]) + const scopeRef = useRef('') + const scope = `${hostId}:${worktreeId}` + scopeRef.current = scope + const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions()) + const pendingDirectoryRetriesRef = useRef>(new Set()) + const directoryCacheRef = useRef({}) + const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [truncated, setTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) - const loadFiles = useCallback(async () => { - if (!client || connState !== 'connected') { - setLoading(false) - setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...') - return - } - setLoading(true) - setError(null) - try { - const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` }) - if (!response.ok) { - throw new Error(response.error?.message || 'Unable to load files') + const loadDirectory = useCallback( + async (relativePath: string) => { + const scope = scopeRef.current + const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath) + const rootLoad = relativePath === '' + + if (!client || connState !== 'connected') { + const message = + connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...' + if (rootLoad) { + const hasLoadedRoot = + (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 + setLoading(false) + // Why: transient reconnects should not blank an already browsable tree. + setError(hasLoadedRoot ? null : message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + return } - const result = (response as RpcSuccess).result as FilesListResult - setFiles(result.files) - setTruncated(result.truncated) - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load files') - } finally { - setLoading(false) - } - }, [client, connState, worktreeId]) + + if (rootLoad) { + setLoading(true) + setError(null) + } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + loading: true + } + })) + + try { + const response = await client.sendRequest('files.readDir', { + worktree: `id:${worktreeId}`, + relativePath + }) + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load files') + } + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const entries = (response as RpcSuccess).result as MobileDirEntry[] + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { entries } + })) + } catch (err) { + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const message = err instanceof Error ? err.message : 'Unable to load files' + if (rootLoad) { + setError(message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + } finally { + if ( + rootLoad && + isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + setLoading(false) + } + } + }, + [client, connState, worktreeId] + ) useEffect(() => { - void loadFiles() - }, [loadFiles]) + scopeRef.current = scope + resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current) + pendingDirectoryRetriesRef.current.clear() + directoryCacheRef.current = {} + setDirectoryCache({}) + setExpanded(new Set()) + setLoading(true) + setError(null) + }, [scope]) - const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files]) + useEffect(() => { + directoryCacheRef.current = directoryCache + }, [directoryCache]) - const toggleDirectory = useCallback((relativePath: string) => { - triggerSelection() - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(relativePath)) { - next.delete(relativePath) - } else { - next.add(relativePath) + useEffect(() => { + void loadDirectory('') + }, [hostId, loadDirectory]) + + useEffect(() => { + if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) { + return + } + const pending = [...pendingDirectoryRetriesRef.current] + pendingDirectoryRetriesRef.current.clear() + for (const relativePath of pending) { + void loadDirectory(relativePath) + } + }, [connState, loadDirectory]) + + const rows = useMemo( + () => flattenDirectoryCache(directoryCache, expanded), + [directoryCache, expanded] + ) + + const toggleDirectory = useCallback( + (relativePath: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(relativePath)) { + next.delete(relativePath) + } else { + next.add(relativePath) + } + return next + }) + const state = getDirectoryCacheState(directoryCache, relativePath) + if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) { + void loadDirectory(relativePath) } - return next - }) - }, []) + }, + [directoryCache, expanded, loadDirectory] + ) + + const retryDirectory = useCallback( + (relativePath: string) => { + if (connState !== 'connected' && hostId) { + pendingDirectoryRetriesRef.current.add(relativePath) + void forceReconnect(hostId) + return + } + void loadDirectory(relativePath) + }, + [connState, forceReconnect, hostId, loadDirectory] + ) const previewFile = useCallback( (relativePath: string, displayName: string) => { - triggerSelection() navigateToMobileFilePreview( router, { @@ -116,66 +225,15 @@ export function MobileFileExplorerPanel(props: { [embedded, hostId, name, onRequestClose, router, worktreeId] ) - const renderItem: ListRenderItem = ({ item }) => { - const isDirectory = item.kind === 'directory' - const isExpanded = expanded.has(item.relativePath) - // Images render in the mobile viewer (via files.readPreview), so a binary - // image is openable; only non-previewable binaries are unavailable. - const previewable = - item.kind !== 'directory' && - canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) - const isImage = item.kind === 'binary' && previewable - const disabled = item.kind === 'binary' && !previewable - const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + const renderItem: ListRenderItem = ({ item }) => { return ( - [ - styles.row, - { paddingLeft: spacing.lg + item.depth * 18 }, - pressed && !disabled && styles.rowPressed, - disabled && styles.rowDisabled - ]} - disabled={disabled} - onPress={() => { - if (isDirectory) { - toggleDirectory(item.relativePath) - } else if (!disabled) { - previewFile(item.relativePath, item.name) - } - }} - accessibilityLabel={ - isDirectory - ? `Open folder ${item.name}` - : disabled - ? `${item.name} unavailable on mobile` - : `Preview file ${item.name}` - } - > - {isDirectory ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - {isDirectory ? ( - - ) : markdown ? ( - - ) : isImage ? ( - - ) : ( - - )} - - - {item.name} - - {disabled ? Unavailable on mobile : null} - - + ) } @@ -206,7 +264,6 @@ export function MobileFileExplorerPanel(props: { {worktreeLabel} - {truncated ? ' - Showing first 5000' : ''} @@ -220,12 +277,12 @@ export function MobileFileExplorerPanel(props: { {error} {/* Why: while disconnected, re-sending the request is useless — revive - the parked transport instead (issue #5049); loadFiles re-runs via + the parked transport instead (issue #5049); loadDirectory re-runs via its effect once the new client connects. */} - connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('') } > Retry diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts new file mode 100644 index 00000000000..86ebfc3c0e8 --- /dev/null +++ b/mobile/src/files/directory-load-revisions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' + +describe('directory-load-revisions', () => { + it('ignores stale duplicate loads for the same directory and scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false) + }) + + it('ignores loads from an old host/worktree scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false) + }) + + it('ignores loads from an old reset generation in the same scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + resetDirectoryLoadRevisions(revisions) + const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false) + }) + + it('tracks directory names that overlap object prototype keys', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true) + }) +}) diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts new file mode 100644 index 00000000000..04153092bbe --- /dev/null +++ b/mobile/src/files/directory-load-revisions.ts @@ -0,0 +1,42 @@ +export type DirectoryLoadRevisions = { + generation: number + revisionsByPath: Map +} + +export type DirectoryLoadToken = { + generation: number + relativePath: string + revision: number + scope: string +} + +export function createDirectoryLoadRevisions(): DirectoryLoadRevisions { + return { generation: 0, revisionsByPath: new Map() } +} + +export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void { + revisions.generation += 1 + revisions.revisionsByPath.clear() +} + +export function beginDirectoryLoad( + revisions: DirectoryLoadRevisions, + scope: string, + relativePath: string +): DirectoryLoadToken { + const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1 + revisions.revisionsByPath.set(relativePath, revision) + return { generation: revisions.generation, relativePath, revision, scope } +} + +export function isCurrentDirectoryLoad( + revisions: DirectoryLoadRevisions, + currentScope: string, + token: DirectoryLoadToken +): boolean { + return ( + currentScope === token.scope && + revisions.generation === token.generation && + revisions.revisionsByPath.get(token.relativePath) === token.revision + ) +} diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index c4777ca2188..bdd9e9225cb 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -1,32 +1,109 @@ import { describe, expect, it } from 'vitest' -import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree' +import { + flattenDirectoryCache, + getMobileFileKind, + isMarkdownPath, + shouldIncludeMobileFileExplorerEntry, + type DirectoryCache, + type MobileDirEntry +} from './file-tree' -function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry { - return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind } +function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry { + return { name, isDirectory, isSymlink } } describe('file-tree', () => { - it('nests files under their directories', () => { - const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')]) - expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md']) - expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe( - 'src/lib/util.ts' - ) + it('flattens cached directories lazily with directories before files', () => { + const cache: DirectoryCache = { + '': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] }, + src: { entries: [entry('app.ts'), entry('lib', true)] }, + 'src/lib': { entries: [entry('util.ts')] } + } + + const collapsed = flattenDirectoryCache(cache, new Set()) + expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt']) + + const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib'])) + expect(expanded.map((row) => row.id)).toEqual([ + 'dir:src', + 'dir:src/lib', + 'file:src/lib/util.ts', + 'file:src/app.ts', + 'file:readme.md', + 'file:zeta.txt' + ]) }) - it('flattens with directories before files and only expands open dirs', () => { - const root = buildTree([entry('src/app.ts'), entry('zeta.txt')]) - const collapsed = flattenTree(root, new Set()) - expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt']) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { + const cache: DirectoryCache = { + '': { + entries: [ + entry('.git', true), + entry('.env'), + entry('node_modules', true), + entry('src', true) + ] + } + } - const expanded = flattenTree(root, new Set(['src'])) - expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt']) + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'dir:src', + 'file:.env' + ]) + expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true) }) - it('preserves the binary kind on flattened rows', () => { - const root = buildTree([entry('assets/logo.png', 'binary')]) - const rows = flattenTree(root, new Set(['assets'])) - expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary') + it('renders inline loading and error rows under expanded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('loading-dir', true), entry('error-dir', true)] }, + 'loading-dir': { entries: [], loading: true }, + 'error-dir': { entries: [], error: 'permission denied' } + } + + const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir'])) + expect(rows.map((row) => row.id)).toEqual([ + 'dir:error-dir', + 'error:error-dir', + 'dir:loading-dir', + 'loading:loading-dir' + ]) + expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({ + kind: 'error', + message: 'permission denied' + }) + }) + + it('preserves symlink metadata for user-initiated activation', () => { + const cache: DirectoryCache = { + '': { entries: [entry('linked-docs', false, true)] } + } + + expect(flattenDirectoryCache(cache, new Set())).toEqual([ + { + id: 'file:linked-docs', + name: 'linked-docs', + relativePath: 'linked-docs', + depth: 0, + kind: 'text', + isSymlink: true + } + ]) + }) + + it('does not treat inherited object keys as loaded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('constructor', true), entry('__proto__', true)] }, + ['__proto__']: { entries: [entry('file.txt')] } + } + + expect( + flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id) + ).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor']) + }) + + it('classifies binary file paths for existing mobile preview behavior', () => { + expect(getMobileFileKind('assets/logo.png')).toBe('binary') + expect(getMobileFileKind('src/app.ts')).toBe('text') }) it('detects markdown paths', () => { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index e453cc152e4..a9283cfa457 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,91 +1,152 @@ -// Pure tree model for the mobile file explorer: turns the flat files.list -// result into a nested directory structure and flattens it into renderable -// rows. Kept out of the screen component so the screen stays under its line cap. +// Pure tree projection for the mobile file explorer. Mobile mirrors desktop +// browse semantics by flattening cached files.readDir results as folders open. -export type MobileFileEntry = { - relativePath: string - basename: string - kind: 'text' | 'binary' +export type MobileDirEntry = { + name: string + isDirectory: boolean + isSymlink?: boolean } -export type FilesListResult = { - files: MobileFileEntry[] - totalCount: number - truncated: boolean -} +export type MobileFileKind = 'text' | 'binary' export type TreeNode = { id: string name: string relativePath: string depth: number - kind: 'directory' | 'text' | 'binary' + kind: 'directory' | MobileFileKind + isSymlink?: boolean } -export type DirectoryNode = { - name: string +export type DirectoryState = { + entries: MobileDirEntry[] + loading?: boolean + error?: string +} + +export type DirectoryCache = Record + +export type InlineStatusNode = { + id: string relativePath: string - directories: Map - files: MobileFileEntry[] + depth: number + kind: 'loading' | 'error' + message?: string } -function createDirectoryNode(name: string, relativePath: string): DirectoryNode { - return { name, relativePath, directories: new Map(), files: [] } -} +export type FileExplorerRow = TreeNode | InlineStatusNode -export function buildTree(files: MobileFileEntry[]): DirectoryNode { - const root = createDirectoryNode('', '') - for (const file of files) { - const parts = file.relativePath.split('/').filter(Boolean) - let current = root - for (let index = 0; index < parts.length - 1; index += 1) { - const name = parts[index]! - const relativePath = parts.slice(0, index + 1).join('/') - let child = current.directories.get(name) - if (!child) { - child = createDirectoryNode(name, relativePath) - current.directories.set(name, child) - } - current = child - } - current.files.push(file) - } - return root -} +const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules']) +const BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) -export function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] { - const rows: TreeNode[] = [] - const visit = (directory: DirectoryNode, depth: number): void => { - const dirs = Array.from(directory.directories.values()).sort((a, b) => - a.name.localeCompare(b.name) - ) - for (const child of dirs) { - rows.push({ - id: `dir:${child.relativePath}`, - name: child.name, - relativePath: child.relativePath, - depth, - kind: 'directory' - }) - if (expanded.has(child.relativePath)) { - visit(child, depth + 1) - } - } - const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename)) - for (const file of files) { - rows.push({ - id: `file:${file.relativePath}`, - name: file.basename, - relativePath: file.relativePath, - depth, - kind: file.kind - }) - } - } - visit(root, 0) +export function flattenDirectoryCache( + cache: DirectoryCache, + expanded: ReadonlySet +): FileExplorerRow[] { + const rows: FileExplorerRow[] = [] + visitDirectory('', 0, cache, expanded, rows) return rows } +function visitDirectory( + relativePath: string, + depth: number, + cache: DirectoryCache, + expanded: ReadonlySet, + rows: FileExplorerRow[] +): void { + const state = getDirectoryCacheState(cache, relativePath) + const entries = state?.entries ?? [] + const visibleEntries = entries + .filter(shouldIncludeMobileFileExplorerEntry) + .sort(compareDirectoryEntries) + + for (const entry of visibleEntries) { + const childPath = joinRelativePath(relativePath, entry.name) + rows.push(toTreeNode(entry, childPath, depth)) + if (entry.isDirectory && expanded.has(childPath)) { + const childState = getDirectoryCacheState(cache, childPath) + if (childState?.loading) { + rows.push({ + id: `loading:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'loading' + }) + } else if (childState?.error) { + rows.push({ + id: `error:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'error', + message: childState.error + }) + } else { + visitDirectory(childPath, depth + 1, cache, expanded, rows) + } + } + } +} + +function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode { + return { + id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`, + name: entry.name, + relativePath, + depth, + kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath), + isSymlink: entry.isSymlink + } +} + +function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) +} + +export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { + return !DESKTOP_EXCLUDED_NAMES.has(entry.name) +} + +export function getDirectoryCacheState( + cache: DirectoryCache, + relativePath: string +): DirectoryState | undefined { + // Why: repository paths are arbitrary object keys; inherited keys like + // "constructor" must not masquerade as loaded directory state. + return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined +} + +export function joinRelativePath(parentPath: string, name: string): string { + return parentPath ? `${parentPath}/${name}` : name +} + +export function getMobileFileKind(relativePath: string): MobileFileKind { + const basename = relativePath.split('/').pop() ?? relativePath + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return 'text' + } + return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text' +} + export function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx new file mode 100644 index 00000000000..36f74ad3b53 --- /dev/null +++ b/mobile/src/files/mobile-file-explorer-row.tsx @@ -0,0 +1,145 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + ChevronDown, + ChevronRight, + File, + FileText, + Folder, + Image as ImageIcon +} from 'lucide-react-native' +import { triggerSelection } from '../platform/haptics' +import { colors, spacing } from '../theme/mobile-theme' +import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { canPreviewMobileFileRow } from './mobile-file-preview-navigation' + +type Props = { + item: FileExplorerRow + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onRetryDirectory: (relativePath: string) => void + onToggleDirectory: (relativePath: string) => void +} + +export function MobileFileExplorerRow(props: Props) { + const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props + + if (item.kind === 'loading') { + return ( + + + + Loading... + + ) + } + + if (item.kind === 'error') { + return ( + + + + {item.message || 'Unable to load folder'} + + [styles.inlineRetryButton, pressed && styles.rowPressed]} + onPress={() => { + triggerSelection() + onRetryDirectory(item.relativePath) + }} + accessibilityLabel={`Retry loading ${item.relativePath}`} + > + Retry + + + ) + } + + if (isTreeNode(item)) { + return ( + + ) + } + + return null +} + +function isTreeNode(item: FileExplorerRow): item is TreeNode { + return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary' +} + +function TreeRow(props: { + item: TreeNode + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onToggleDirectory: (relativePath: string) => void +}) { + const { item, expanded, onPreviewFile, onToggleDirectory } = props + const isDirectory = item.kind === 'directory' + const isExpanded = expanded.has(item.relativePath) + // Images render in the mobile viewer (via files.readPreview), so a binary + // image is openable; only non-previewable binaries are unavailable. + const previewable = + item.kind !== 'directory' && + canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) + const isImage = item.kind === 'binary' && previewable + const disabled = item.kind === 'binary' && !previewable + const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + + return ( + [ + styles.row, + { paddingLeft: spacing.lg + item.depth * 18 }, + pressed && !disabled && styles.rowPressed, + disabled && styles.rowDisabled + ]} + disabled={disabled} + onPress={() => { + triggerSelection() + if (isDirectory) { + onToggleDirectory(item.relativePath) + } else if (!disabled) { + onPreviewFile(item.relativePath, item.name) + } + }} + accessibilityLabel={ + isDirectory + ? `Open folder ${item.name}` + : disabled + ? `${item.name} unavailable on mobile` + : `Preview file ${item.name}` + } + > + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + {isDirectory ? ( + + ) : markdown ? ( + + ) : isImage ? ( + + ) : ( + + )} + + + {item.name} + + {disabled ? Unavailable on mobile : null} + + + ) +} diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/mobile-file-explorer-styles.ts index 339dd5519de..5df9710be10 100644 --- a/mobile/src/files/mobile-file-explorer-styles.ts +++ b/mobile/src/files/mobile-file-explorer-styles.ts @@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({ color: colors.textMuted, fontSize: 11 }, + inlineStatusRow: { + minHeight: 36, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingRight: spacing.md + }, + inlineStatusText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + inlineErrorText: { + flex: 1, + minWidth: 0, + color: colors.statusRed, + fontSize: typography.metaSize + }, + inlineRetryButton: { + minHeight: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md + }, + inlineRetryText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, state: { flex: 1, alignItems: 'center', diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 59efa6abfa2..a5418500c1d 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.openDiff', 'files.read', 'files.readChunk', + 'files.readDir', 'files.readPreview', 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', From ee2e3a30a50a68bbfa8c45e0da463b914a65c851 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:59:57 +0000 Subject: [PATCH 03/46] release: v1.4.126 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e67ae77efc0..b9fe4f6721f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.126-rc.0", + "version": "1.4.126", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 7237c8e592319affbbbb7bfcdfcdb021adac67de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:39:20 -0700 Subject: [PATCH 04/46] Route title-status consumers through one pane-agent-evidence seam (title evidence plan, PR 2) (#7600) Co-authored-by: Orca --- .../use-native-chat-toggle-shortcut.ts | 6 +- .../components/sidebar/WorktreeCard.test.ts | 78 +--------- .../src/components/sidebar/smart-attention.ts | 6 +- .../sidebar/worktree-card-status.ts | 121 -------------- .../worktree-title-derived-agent-rows.ts | 13 +- .../workspace-space-presentation.ts | 6 +- .../src/components/tab-bar/TabBar.tsx | 6 +- .../terminal-pane/cache-timer-seeding.ts | 5 +- .../native-chat-leaf-title-agent.ts | 9 +- .../terminal-pane/pty-connection.ts | 4 +- .../use-notification-dispatch.ts | 4 +- .../src/lib/active-agent-note-target.ts | 10 +- src/renderer/src/lib/agent-ready-wait.ts | 4 +- .../src/lib/agent-send-title-status.ts | 7 +- .../src/lib/agent-status-terminal-title.ts | 4 +- src/renderer/src/lib/agent-status.ts | 38 ++--- .../src/lib/pane-agent-evidence.test.ts | 147 ++++++++++++++++++ src/renderer/src/lib/pane-agent-evidence.ts | 121 ++++++++++++++ src/renderer/src/lib/running-agent-targets.ts | 22 ++- src/renderer/src/lib/use-tab-agent.ts | 2 - src/renderer/src/lib/worktree-status.ts | 6 +- .../src/store/slices/terminal-helpers.ts | 4 +- src/renderer/src/store/slices/terminals.ts | 9 +- .../src/store/slices/workspace-cleanup.ts | 6 +- 24 files changed, 359 insertions(+), 279 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/worktree-card-status.ts create mode 100644 src/renderer/src/lib/pane-agent-evidence.test.ts create mode 100644 src/renderer/src/lib/pane-agent-evidence.ts diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index cb5a1f7a33e..5ae84dde522 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalPaneLayoutNode } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' @@ -72,8 +72,8 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive agentStatusByPaneKey: state.agentStatusByPaneKey }) const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) - ? (resolveTabAgentFromTitle(tab.label ?? '') ?? - (terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null)) + ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? + (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null if ( !canToggleNativeChat({ diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 36ac370dbed..abc03a3c4fd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -20,7 +20,6 @@ vi.mock('@/lib/agent-status', () => ({ import { getWorktreeStatus } from '@/lib/worktree-status' import { shouldBeginWorktreeRename } from './WorktreeCard' -import { deriveWorktreeCardStatus } from './worktree-card-status' function makeTerminalTab(title: string): TerminalTab { return { @@ -35,22 +34,6 @@ function makeTerminalTab(title: string): TerminalTab { } } -function makeAgentStatusEntry(args: { - paneKey: string - state: AgentStatusEntry['state'] - updatedAt?: number -}): AgentStatusEntry { - const updatedAt = args.updatedAt ?? 1_000 - return { - paneKey: args.paneKey, - state: args.state, - prompt: '', - updatedAt, - stateStartedAt: updatedAt, - stateHistory: [] - } -} - describe('getWorktreeStatus', () => { it('treats browser-only worktrees as active', () => { expect(getWorktreeStatus([], [{ id: 'browser-1' }], {})).toBe('active') @@ -59,67 +42,22 @@ describe('getWorktreeStatus', () => { it('keeps terminal agent states higher priority than browser presence', () => { // Why: liveness gate now requires ptyIdsByTabId, not tab.ptyId. Pass a // populated live-pty map so this assertion exercises the live-tab branch. + // Titles are real classifiable shapes: getWorktreeStatus reads the shared + // classifier through pane-agent-evidence, which this file does not mock. const livePtyIds = { 'tab-1': ['pty-1'] } expect( - getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus( + [makeTerminalTab('Claude - action required')], + [{ id: 'browser-1' }], + livePtyIds + ) ).toBe('permission') expect( - getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds) ).toBe('working') }) }) -describe('deriveWorktreeCardStatus', () => { - it('keeps split-pane heuristics for panes without fresh explicit status', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude [done]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude [done]', - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('working') - }) - - it('lets fresh explicit status win over the matching pane title heuristic', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('codex [working]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:2', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('done') - }) - - it('stays active when the only live terminal signal is the Claude agents screen', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude agents')], - browserTabs: [], - worktreeAgentEntries: [], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude agents' - } - }, - now: 1_000 - }) - - expect(status).toBe('active') - }) -}) - describe('shouldBeginWorktreeRename', () => { it('matches unscoped legacy rename requests by worktree id', () => { expect(shouldBeginWorktreeRename({ worktreeId: 'wt-1' }, 'wt-1', 'all:wt-1')).toBe(true) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 4ca27a7c47d..ae0d7542da3 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' @@ -308,7 +308,7 @@ export function buildAttentionByWorktree( } panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(title), + status: classifyTitleActivity(title), worktreeLastActivityAt: worktree.lastActivityAt }) } @@ -318,7 +318,7 @@ export function buildAttentionByWorktree( // titles or hook entries exist for this tab. panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(tab.title), + status: classifyTitleActivity(tab.title), worktreeLastActivityAt: worktree.lastActivityAt }) } diff --git a/src/renderer/src/components/sidebar/worktree-card-status.ts b/src/renderer/src/components/sidebar/worktree-card-status.ts deleted file mode 100644 index 61daeb74c9a..00000000000 --- a/src/renderer/src/components/sidebar/worktree-card-status.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' - -type WorktreeCardStatusInput = { - tabs: Pick[] - browserTabs: { id: string }[] - worktreeAgentEntries: AgentStatusEntry[] - runtimePaneTitlesByTabId: Record> - now?: number -} - -export function deriveWorktreeCardStatus({ - tabs, - browserTabs, - worktreeAgentEntries, - runtimePaneTitlesByTabId, - now = Date.now() -}: WorktreeCardStatusInput): WorktreeStatus { - const liveTabs = tabs.filter((tab) => tab.ptyId) - // Why: browser-only worktrees are still active from the user's point of - // view even when they have no PTY-backed terminal. The sidebar filter - // already treats them as active, so every navigation surface must reuse - // that rule instead of showing a misleading inactive dot. - const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0 - if (!hasTerminals) { - return 'inactive' - } - - const freshByTabId = new Map() - const explicitPaneIdsByTabId = new Map>() - for (const entry of worktreeAgentEntries) { - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - continue - } - const colonIdx = entry.paneKey.indexOf(':') - // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no - // colon or leading colon) rather than bucketing under "". - if (colonIdx <= 0) { - continue - } - const tabId = entry.paneKey.slice(0, colonIdx) - const paneId = entry.paneKey.slice(colonIdx + 1) - const bucket = freshByTabId.get(tabId) - if (bucket) { - bucket.push(entry) - } else { - freshByTabId.set(tabId, [entry]) - } - const explicitPaneIds = explicitPaneIdsByTabId.get(tabId) ?? new Set() - explicitPaneIds.add(paneId) - explicitPaneIdsByTabId.set(tabId, explicitPaneIds) - } - - let hasPermission = false - let hasWorking = false - let hasDone = false - for (const tab of liveTabs) { - const fresh = freshByTabId.get(tab.id) - if (fresh && fresh.length > 0) { - if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) { - hasPermission = true - } else if (fresh.some((e) => e.state === 'working')) { - hasWorking = true - } else if (fresh.some((e) => e.state === 'done')) { - hasDone = true - } - } - - const explicitPaneIds = explicitPaneIdsByTabId.get(tab.id) - const paneTitles = runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - for (const [paneId, title] of Object.entries(paneTitles)) { - // Why: explicit hook status only supersedes the matching pane. Other - // split panes still need the title heuristic when hooks are absent. - if (explicitPaneIds?.has(paneId)) { - continue - } - const heuristic = detectAgentStatusFromTitle(title) - if (heuristic === 'permission') { - hasPermission = true - break - } - if (heuristic === 'working') { - hasWorking = true - } - } - continue - } - - if (fresh && fresh.length > 0) { - continue - } - - const heuristic = detectAgentStatusFromTitle(tab.title) - if (heuristic === 'permission') { - hasPermission = true - } else if (heuristic === 'working') { - hasWorking = true - } - } - - if (hasPermission) { - return 'permission' - } - if (hasWorking) { - return 'working' - } - // Why: surface 'done' as its own status so the sidebar dot turns blue - // (sky-500/80) — matching the dashboard's done color. A completed agent - // still has a live terminal, so 'inactive' would be misleading; calling - // it 'done' keeps the two surfaces in agreement on what the agent is. - if (hasDone) { - return 'done' - } - return 'active' -} diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 2b5efddebee..1c862b70505 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -1,9 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { - detectAgentStatusFromTitle, - getAgentLabel, - isClaudeManagementTitle -} from '@/lib/agent-status' +import { isClaudeManagementTitle } from '@/lib/agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { AgentStatusEntry, @@ -135,8 +132,8 @@ function buildTitleDerivedAgentRow(args: { // Why: `claude agents` is a live Claude Code Agent Teams surface, but the // shared detector keeps it neutral so runtime liveness probes do not treat // the management/list screen as active work. - const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title) - const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title) + const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title) + const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title) if (!status || !label) { return null } @@ -199,7 +196,7 @@ export function resolveAgentTypeFromTerminalTitle( return null } const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) - const label = getAgentLabel(normalizedTitle) + const label = resolveTitleActivityLabel(normalizedTitle) return label ? (resolveCompatibleAgentTypeForOwner( resolveTitleDerivedAgentType(normalizedTitle, label), diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 85274483d96..ad09532a652 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { AGENT_STATUS_STALE_AFTER_MS, @@ -82,12 +82,12 @@ function countTitleActiveAgentsForTab( const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { return Object.values(paneTitles).filter((title) => { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) return status === 'working' || status === 'permission' }).length } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) return status === 'working' || status === 'permission' ? 1 : 0 } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 194174a59a2..0e3f79e600e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -79,7 +79,7 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -1103,8 +1103,8 @@ function TabBarInner({ // Carry the agent *identity* (not just "an agent exists") so the // native-chat gate can reject unsupported agents like Grok. const resolvedAgent = - resolveTabAgentFromTitle(unifiedTabForItem?.label ?? '') ?? - resolveTabAgentFromTitle(terminalTab.title) + resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? + resolveCommittedTitleAgentType(terminalTab.title) // Key the live-agent lookup by the backing terminal tab id — // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. diff --git a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts index 6f788af3e28..7b1d16522d5 100644 --- a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts +++ b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts @@ -1,4 +1,5 @@ -import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function shouldSeedCacheTimerOnInitialTitle(args: { rawTitle: string @@ -12,7 +13,7 @@ export function shouldSeedCacheTimerOnInitialTitle(args: { return false } - const status = detectAgentStatusFromTitle(rawTitle) + const status = classifyTitleActivity(rawTitle) if (status === null || status === 'working') { return false } diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts index e3527bf11ac..20f24fb6986 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -1,5 +1,5 @@ import type { TuiAgent } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' export type NativeChatLeafTitlePane = { id: number @@ -26,7 +26,7 @@ export function resolveNativeChatLeafTitleAgent({ } const targetPane = panes.find((pane) => pane.leafId === leafId) const paneAgent = targetPane - ? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '') + ? resolveCommittedTitleAgentType(runtimePaneTitlesByPaneId[targetPane.id] ?? '') : null if (paneAgent) { return paneAgent @@ -36,5 +36,8 @@ export function resolveNativeChatLeafTitleAgent({ if (panes.length > 1) { return null } - return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '') + return ( + resolveCommittedTitleAgentType(tabLabel ?? '') ?? + resolveCommittedTitleAgentType(terminalTitle ?? '') + ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ffe958ddb27..8cefac661e7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -161,7 +161,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess, recognizeAgentProcessFromCommandLine @@ -1810,7 +1810,7 @@ export function connectPanePty( (entry) => entry.id === deps.tabId ) const title = currentTitle ?? tab?.title - if (!title || resolveExplicitTerminalTitleAgentType(title) === null) { + if (!title || resolveCommittedTitleAgentType(title) === null) { return } const neutralTitle = neutralTerminalTitle() diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index d1a97b33aed..2a0c2f4f3e6 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' @@ -52,7 +52,7 @@ export function dispatchTerminalNotification( // not lend its prompt/agentType or timing id to this notification. const explicitTitleAgentType = event.source === 'agent-task-complete' && event.terminalTitle - ? resolveExplicitTerminalTitleAgentType(event.terminalTitle) + ? resolveCommittedTitleAgentType(event.terminalTitle) : null const storedAgentStatus = event.source === 'agent-task-complete' && event.paneKey diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 5bbc1253a4f..9ee0dfc511d 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -15,10 +15,10 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../shared/types' import { - detectAgentStatusFromTitle, - getAgentLabel, - isExplicitAgentStatusFresh -} from './agent-status' + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveTitleActivityLabel +} from '@/lib/pane-agent-evidence' import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000 @@ -271,5 +271,5 @@ function getFocusedRuntimePaneTitle( } function isRecognizedAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null + return classifyTitleActivity(title) !== null && resolveTitleActivityLabel(title) !== null } diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 70cbb4ade04..8bd7cb8677b 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from './tui-agent-startup' import { useAppStore } from '@/store' @@ -51,7 +51,7 @@ function titleSuggestsReady(tabId: string): boolean { } } } - return titles.some((title) => detectAgentStatusFromTitle(title) === 'idle') + return titles.some((title) => classifyTitleActivity(title) === 'idle') } /** diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts index 11f949fde88..9ae50293693 100644 --- a/src/renderer/src/lib/agent-send-title-status.ts +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -1,4 +1,5 @@ -import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status' +import type { AgentStatus } from './agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -6,11 +7,11 @@ const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null { - if (!title || getAgentLabel(title) === null) { + if (!title || resolveTitleActivityLabel(title) === null) { return null } - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status !== 'idle') { return status } diff --git a/src/renderer/src/lib/agent-status-terminal-title.ts b/src/renderer/src/lib/agent-status-terminal-title.ts index d3162d52684..c5c7bca56d4 100644 --- a/src/renderer/src/lib/agent-status-terminal-title.ts +++ b/src/renderer/src/lib/agent-status-terminal-title.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from './pane-agent-evidence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { getSyntheticAgentTerminalTitle, @@ -26,7 +26,7 @@ function shouldReplaceCurrentTitle( if (!currentTitle?.trim()) { return true } - const currentStatus = detectAgentStatusFromTitle(currentTitle) + const currentStatus = classifyTitleActivity(currentTitle) if (currentStatus === 'working') { return true } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 39618185154..141da5c2a3a 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,9 +1,5 @@ import type { TerminalTab, TuiAgent, Worktree } from '../../../shared/types' -import type { - AgentStatusEntry, - AgentStatusState, - AgentType -} from '../../../shared/agent-status-types' +import type { AgentStatusState, AgentType } from '../../../shared/agent-status-types' import { tabHasLivePty } from './tab-has-live-pty' import type { WorktreeStatus } from './worktree-status' import { tuiAgentToAgentKind } from '../../../shared/agent-kind' @@ -23,11 +19,8 @@ export { isClaudeManagementTitle, getAgentLabel } from '../../../shared/agent-detection' -import { - type AgentStatus, - detectAgentStatusFromTitle, - getAgentLabel -} from '../../../shared/agent-detection' +import type { AgentStatus } from '../../../shared/agent-detection' +import { classifyTitleActivity, resolveTitleActivityLabel } from './pane-agent-evidence' type AgentQueryArgs = { tabsByWorktree: Record @@ -84,8 +77,8 @@ export function getWorkingAgentsPerWorktree({ const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { for (const [paneIdStr, title] of Object.entries(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { - const label = getAgentLabel(title) + if (classifyTitleActivity(title) === 'working') { + const label = resolveTitleActivityLabel(title) if (label) { agents.push({ label, @@ -96,8 +89,8 @@ export function getWorkingAgentsPerWorktree({ } } } - } else if (detectAgentStatusFromTitle(tab.title) === 'working') { - const label = getAgentLabel(tab.title) + } else if (classifyTitleActivity(tab.title) === 'working') { + const label = resolveTitleActivityLabel(tab.title) if (label) { agents.push({ label, status: 'working', tabId: tab.id, paneId: null }) } @@ -210,16 +203,9 @@ export function agentKindForAgentType(agentType: AgentType | null | undefined): return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other' } -// Why: explicit agent status entries (from hook-based reports) can go stale if -// the agent process exits without sending a final update. This helper lets -// callers decide whether to trust the entry based on a configurable TTL. -export function isExplicitAgentStatusFresh( - entry: Pick, - now: number, - staleAfterMs: number -): boolean { - return now - entry.updatedAt <= staleAfterMs -} +// Why: the freshness gate moved into the pane-agent-evidence resolvers; the +// re-export keeps this module's many existing importers unchanged. +export { isExplicitAgentStatusFresh } from './pane-agent-evidence' /** * Map an explicit AgentStatusState to the visual Status used by @@ -297,13 +283,13 @@ function countWorkingAgentsForTab( // (for example restored-but-unvisited worktrees). if (paneTitles && Object.keys(paneTitles).length > 0) { for (const title of Object.values(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { + if (classifyTitleActivity(title) === 'working') { count += 1 } } return count } - if (detectAgentStatusFromTitle(tab.title) === 'working') { + if (classifyTitleActivity(tab.title) === 'working') { count += 1 } return count diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts new file mode 100644 index 00000000000..0bd618e2940 --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../shared/agent-status-types' +import { + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveCommittedTitleAgentType, + resolvePaneAgentActivity, + resolveTitleActivityLabel +} from './pane-agent-evidence' + +const NOW = 1_700_000_000_000 + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:leaf-1', + agentType: 'claude', + state: 'working', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + ...overrides + } as AgentStatusEntry +} + +describe('isExplicitAgentStatusFresh', () => { + it('accepts an entry exactly at the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(true) + }) + + it('rejects an entry past the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(false) + }) +}) + +describe('classifyTitleActivity', () => { + it('classifies working, permission, idle, and unclassifiable titles', () => { + expect(classifyTitleActivity('mimo working')).toBe('working') + expect(classifyTitleActivity('Claude - action required')).toBe('permission') + expect(classifyTitleActivity('✳ Claude Code ready')).toBe('idle') + expect(classifyTitleActivity('vim')).toBe(null) + }) +}) + +describe('title agent identity facets', () => { + it('splits the bare Claude spinner into activity label without committed identity', () => { + expect(resolveTitleActivityLabel('⠋ compiling everything')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('⠋ compiling everything')).toBe(null) + }) + + it('commits identity when the title names the agent explicitly', () => { + expect(resolveTitleActivityLabel('✳ Claude Code working')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('✳ Claude Code working')).toBe('claude') + }) + + it('returns neither facet for a plain shell title', () => { + expect(resolveTitleActivityLabel('zsh')).toBe(null) + expect(resolveCommittedTitleAgentType('zsh')).toBe(null) + }) +}) + +describe('resolvePaneAgentActivity', () => { + it('reports a fresh hook row as the authoritative source and keeps the title layer visible', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'waiting' }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: 'waiting', + hookAgentType: 'claude', + titleStatus: 'working', + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('treats a stale hook row as absent and falls back to the title', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe(null) + expect(decision.source).toBe('title') + expect(decision.confidence).toBe('fallback') + expect(decision.titleStatus).toBe('working') + expect(decision.livePtyRequired).toBe(false) + }) + + it('flags title-only evidence without a live PTY so liveness-gated consumers drop it', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: '⠋ running the tests', + hasLivePty: false, + now: NOW + }) + expect(decision.source).toBe('title') + expect(decision.livePtyRequired).toBe(true) + }) + + it('reports none when there is no fresh hook and the title carries no status', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: 'bash', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('passes hook state through raw, including done', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'done' }), + liveTitle: null, + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe('done') + expect(decision.source).toBe('hook') + }) +}) diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts new file mode 100644 index 00000000000..4d67758951a --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -0,0 +1,121 @@ +import type { AgentStatus } from '../../../shared/agent-detection' +import { detectAgentStatusFromTitle, getAgentLabel } from '../../../shared/agent-detection' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../../shared/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState, + type AgentType +} from '../../../shared/agent-status-types' + +// Why: explicit agent status entries (from hook-based reports) can go stale if +// the agent process exits without sending a final update. This helper lets +// callers decide whether to trust the entry based on a configurable TTL. +// (Moved here from agent-status.ts so the evidence resolvers below and the +// aggregate consumers share one gate without an import cycle.) +export function isExplicitAgentStatusFresh( + entry: Pick, + now: number, + staleAfterMs: number +): boolean { + return now - entry.updatedAt <= staleAfterMs +} + +/** + * Title-only activity classification for consumers whose product rule really + * is "what does this title say" (timer seeding, ready-wait polling, sort-epoch + * comparison, synthetic-title writing) — they combine no hook/liveness + * evidence, so routing them through the pane resolver would misstate intent. + */ +export function classifyTitleActivity(title: string): AgentStatus | null { + return detectAgentStatusFromTitle(title) +} + +/** + * The two facets of title-derived agent identity. The ACTIVITY LABEL treats + * Claude's bare status prefixes (spinner/`✳`/`. `/`* `) as Claude activity; + * COMMITTED identity rejects them — evidence that something runs is not proof + * of who. Consumers historically split on this by accident; pick the facet + * that matches the product rule, never both out of habit. + */ +export function resolveTitleActivityLabel(title: string): string | null { + return getAgentLabel(title) +} + +/** See resolveTitleActivityLabel — the strict facet for identity decisions. */ +export function resolveCommittedTitleAgentType(title: string): TuiAgent | null { + return resolveExplicitTerminalTitleAgentType(title) +} + +/** + * Combined pane activity evidence for aggregate consumers. The hook and title + * layers are exposed separately on purpose: consumers legitimately combine + * them differently (send targets let a live permission title override a fresh + * working hook; the worktree dot suppresses titles on hook-covered panes), so + * a single merged status would silently change behavior. + */ +export type AgentActivityDecision = { + /** Fresh (within AGENT_STATUS_STALE_AFTER_MS), pane-scoped hook state; null when absent or stale. */ + hookState: AgentStatusState | null + /** Agent identity from the fresh hook row, when one exists. */ + hookAgentType: AgentType | undefined + /** Title-derived status of the pane's live title, independent of hook state. */ + titleStatus: AgentStatus | null + /** Which evidence layer holds the strongest current claim. */ + source: 'hook' | 'title' | 'none' + confidence: 'authoritative' | 'fallback' + /** True when the only claim is a title without live-PTY proof — liveness-gated consumers must treat it as absent. */ + livePtyRequired: boolean +} + +export type ResolvePaneAgentActivityInput = { + explicitEntry: AgentStatusEntry | undefined + liveTitle: string | null + hasLivePty: boolean + now: number +} + +export function resolvePaneAgentActivity( + input: ResolvePaneAgentActivityInput +): AgentActivityDecision { + const freshEntry = + input.explicitEntry && + isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS) + ? input.explicitEntry + : null + const titleStatus = input.liveTitle !== null ? detectAgentStatusFromTitle(input.liveTitle) : null + if (freshEntry) { + return { + hookState: freshEntry.state, + hookAgentType: freshEntry.agentType, + titleStatus, + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + } + } + if (titleStatus !== null) { + return { + hookState: null, + hookAgentType: undefined, + titleStatus, + source: 'title', + confidence: 'fallback', + livePtyRequired: !input.hasLivePty + } + } + return { + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + } +} + +// Deliberately absent: a resolvePaneAgentOwner precedence resolver. The only +// Phase 2 identity consumer (native-chat toggle) reads hook identity without a +// freshness gate, so a gated owner resolver would change its behavior; the +// owner resolver lands with its first real consumer in a later slice. diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 332f419dbd2..eedc06e36ba 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -1,11 +1,8 @@ import type { AppState } from '@/store/types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../shared/agent-status-types' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { isExplicitAgentStatusFresh } from './agent-status' +import { resolvePaneAgentActivity } from '@/lib/pane-agent-evidence' import { detectAgentSendTitleStatus } from './agent-send-title-status' import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' @@ -58,12 +55,23 @@ export function deriveRunningAgentSendTargets( : null let disabledReason: string | undefined + // Why: the shared resolver gates hook freshness; a null hookState means the + // entry is stale (entries here always exist), and otherwise carries the + // fresh entry.state. The live-title layer stays local because it needs the + // send-gated detector (label + strict idle-send gate), which the resolver's + // raw titleStatus does not reproduce. + const decision = resolvePaneAgentActivity({ + explicitEntry: entry, + liveTitle: null, + hasLivePty: ptyId !== null, + now + }) // Why: hook-backed rows can go stale while the same PTY is still a live // agent; live titles are the runtime proof that the row remains targetable. const liveTitleStatus = ptyId ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) : null - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (decision.hookState === null) { if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === null) { @@ -71,7 +79,7 @@ export function deriveRunningAgentSendTargets( } } else if (!ptyId) { disabledReason = 'Terminal is no longer available' - } else if (entry.state === 'blocked' || entry.state === 'waiting') { + } else if (decision.hookState === 'blocked' || decision.hookState === 'waiting') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 6253ced22f4..40bf92d6f7b 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -13,8 +13,6 @@ import { import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import type { TerminalTab, TuiAgent } from '../../../shared/types' -export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type' - // A shell name, or the tab's neutral default title — where Orca's // inferred-interrupt reset parks it. Blank titles are no evidence either way. function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 5018c731055..0c3c87bf062 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' import type { @@ -91,7 +91,7 @@ function tabHasStatus( ) { continue } - if (detectAgentStatusFromTitle(title) === status) { + if (classifyTitleActivity(title) === status) { return true } } @@ -103,7 +103,7 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return detectAgentStatusFromTitle(tab.title) === status + return classifyTitleActivity(tab.title) === status } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 3976605569c..7f42c049ab6 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -1,5 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function emptyLayoutSnapshot(): TerminalLayoutSnapshot { return { @@ -37,5 +37,5 @@ function getResetTitle(tab: TerminalTab, index: number): string { // Why: reset any recognized agent title on hydration. The prior-session // agent is no longer running after a restart, so showing a stale // "Claude done" or spinner would be misleading. - return detectAgentStatusFromTitle(tab.title) ? fallbackTitle : tab.title + return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 260e642c7bf..9cf68efd612 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -40,7 +40,8 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { dedupeTabOrder, @@ -872,7 +873,7 @@ export const createTerminalSlice: StateCreator if (!tab.title || !isClaudeAgent(tab.title)) { continue } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) if (status === null || status === 'working') { continue } @@ -1631,7 +1632,7 @@ export const createTerminalSlice: StateCreator // event fires. Bumping only on classification change keeps incidental // title noise (spinner frame, prompt suffix) from churning the sidebar. const classificationChanged = - detectAgentStatusFromTitle(prevTitle ?? '') !== detectAgentStatusFromTitle(title) + classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) // Why: locate the owning worktree so we can suppress the sortEpoch // bump when the changing pane lives in the active worktree. Title // changes there are side-effects of the user's click (PTY remount on @@ -1674,7 +1675,7 @@ export const createTerminalSlice: StateCreator // Why: clearing a 'working'/'permission'-classified title back to none // changes the title-heuristic verdict for that pane, so the smart sort // needs a re-sort. See setRuntimePaneTitle for the rationale. - const hadClassification = detectAgentStatusFromTitle(prevTitle ?? '') !== null + const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null // Why: same active-worktree gate as setRuntimePaneTitle — clears that // fire as a side-effect of a click-driven PTY teardown in the active // worktree must not re-rank the sidebar. Skip bumping when no owner is diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 357e4e74a74..502cead194a 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -21,7 +21,7 @@ import { type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' export type WorkspaceCleanupFailure = { @@ -737,7 +737,7 @@ function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string const titles = paneTitles && Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title] for (const title of titles) { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status === 'working' || status === 'permission') { return true } @@ -812,7 +812,7 @@ function hasIdleAgentTitleForPty( } function isIdleAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) === 'idle' + return classifyTitleActivity(title) === 'idle' } function getPaneKeyTabId(paneKey: AgentStatusEntry['paneKey']): string { From ec52db6d204610b8aa9790bd15db8e8e55b2d60f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:43:05 -0700 Subject: [PATCH 05/46] perf(main): drop localhost label routes when a worktree is removed (#7557) Co-authored-by: Orca --- src/main/ipc/worktrees.ts | 4 +++ .../localhost-worktree-label-proxy.test.ts | 29 +++++++++++++++++++ src/main/localhost-worktree-label-proxy.ts | 12 ++++++++ 3 files changed, 45 insertions(+) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c7ff8f45c82..8b966e2b537 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { } import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, @@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin // drop process-local caches before the same ID can point at a new workspace. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate + // in the proxy's route maps for the rest of the session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue // entries do not retain it for the rest of the session (memory creep). diff --git a/src/main/localhost-worktree-label-proxy.test.ts b/src/main/localhost-worktree-label-proxy.test.ts index 49a5e866ec1..7d499209847 100644 --- a/src/main/localhost-worktree-label-proxy.test.ts +++ b/src/main/localhost-worktree-label-proxy.test.ts @@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => { expect(result.status).toBe(404) }) + + it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => { + const port = await startUpstream((_request, response) => { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('ok') + }) + const proxy = new LocalhostWorktreeLabelProxy() + const a = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-a', + worktreeId: 'wt-a' + }) + const b = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-b', + worktreeId: 'wt-b' + }) + + expect((await fetchThroughProxy(a.url)).status).toBe(200) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + + proxy.unregisterWorktree('wt-a') + + // The removed worktree's label no longer routes; the other worktree is unaffected. + expect((await fetchThroughProxy(a.url)).status).toBe(404) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + }) }) diff --git a/src/main/localhost-worktree-label-proxy.ts b/src/main/localhost-worktree-label-proxy.ts index b2f8152707b..acc1164d1ee 100644 --- a/src/main/localhost-worktree-label-proxy.ts +++ b/src/main/localhost-worktree-label-proxy.ts @@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy { } } + // Why: routes are added per (worktreeId, targetUrl) but were never removed, so + // labels for deleted worktrees accumulated in both maps for the whole session. + // Drop them when the worktree is torn down. The shared http.Server stays up. + unregisterWorktree(worktreeId: string): void { + for (const [label, route] of this.routes) { + if (route.worktreeId === worktreeId) { + this.routes.delete(label) + this.routeKeys.delete(route.routeKey) + } + } + } + private async ensureServer(): Promise { if (this.server && this.listenPort !== null) { return From c4c934cf7cbb9a238fb33580ee68dc668271f840 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:43:43 -0700 Subject: [PATCH 06/46] perf(main): bound authorized-external-path set with LRU eviction (#7556) Co-authored-by: Orca --- src/main/ipc/filesystem-auth.test.ts | 42 ++++++++++++++++++++++++++++ src/main/ipc/filesystem-auth.ts | 26 +++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 979d6b5611f..c6fa78b126d 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees' import { listRepoWorktrees } from '../repo-worktrees' import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types' import { + AUTHORIZED_EXTERNAL_PATHS_MAX, + authorizeExternalPath, invalidateAuthorizedRootsCache, isDescendantOrEqual, + isPathAllowed, rebuildAuthorizedRootsCache, resolveAuthorizedPath, resolveRegisteredWorktreePath, @@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => { } }) }) + +describe('filesystem-auth authorized external path bound', () => { + // Empty allow-list store, so a path is allowed only if it (or an ancestor) is + // in the session-authorized external-path set. + const emptyStore = makeStore([]) + const flood = (n: number): string => + resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`) + + it('bounds the authorized external path set with LRU eviction', () => { + const keep = resolve('/leak-audit-ext/keep') + authorizeExternalPath(keep) + + // Flood past the cap with distinct external paths, re-authorizing `keep` + // periodically so LRU keeps it hot. + const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200 + for (let i = 0; i < total; i += 1) { + authorizeExternalPath(flood(i)) + if (i % 250 === 0) { + authorizeExternalPath(keep) + } + } + + // The oldest never-re-touched entries fell out of the bounded set... + expect(isPathAllowed(flood(0), emptyStore)).toBe(false) + // ...while the periodically re-authorized path and the most recent survive. + expect(isPathAllowed(keep, emptyStore)).toBe(true) + expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true) + }) + + it('re-authorizes an evicted path on next use (self-healing)', () => { + const path = resolve('/leak-audit-ext/evicted-then-reused') + for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) { + authorizeExternalPath(flood(100_000 + i)) + } + expect(isPathAllowed(path, emptyStore)).toBe(false) + authorizeExternalPath(path) + expect(isPathAllowed(path, emptyStore)).toBe(true) + }) +}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index f931ebe119b..7fee4ca1990 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' +// Why: authorized external paths accumulate for the whole session (file drops, +// terminal link opens, editor/composer/notebook opens). Bound the set with LRU +// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long +// session touching many distinct external files cannot grow it (or the O(n) auth +// scan in isPathAllowed) without limit. Every caller re-authorizes a path right +// before operating on it, so evicting a stale entry is self-healing and does not +// weaken the security boundary. +export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096 const authorizedExternalPaths = new Set() const registeredWorktreeRoots = new Set() const registeredWorktreeRootsByRepo = new Map>() @@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 type FolderScopeStore = Pick & Partial> +function rememberAuthorizedExternalPath(path: string): void { + // Delete-then-add keeps re-authorized (actively used) paths most-recent so + // eviction only sheds the oldest never-re-touched entries. + authorizedExternalPaths.delete(path) + authorizedExternalPaths.add(path) + while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) { + const oldest = authorizedExternalPaths.keys().next().value + if (oldest === undefined) { + break + } + authorizedExternalPaths.delete(oldest) + } +} + export function authorizeExternalPath(targetPath: string): void { const resolvedTarget = resolve(targetPath) - authorizedExternalPaths.add(resolvedTarget) + rememberAuthorizedExternalPath(resolvedTarget) try { // Why: macOS canonicalizes /tmp to /private/tmp during read authorization. - authorizedExternalPaths.add(realpathSync(resolvedTarget)) + rememberAuthorizedExternalPath(realpathSync(resolvedTarget)) } catch {} } From aa3936c1e0e4be9c2bd93eb1e0d8644742aa4963 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:44:18 -0700 Subject: [PATCH 07/46] perf(main): bound agent-hook closed-tab suppression set (#7561) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 27 ++++++++++++++++++++++++++- src/main/agent-hooks/server.ts | 16 ++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 46ff181a73d..91b0dc66051 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -13,7 +13,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AgentHookServer, agentHookServer, _internals } from './server' +import { + AgentHookServer, + agentHookServer, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + _internals +} from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_STALE_AFTER_MS, @@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) }) + +describe('AgentHookServer closed-tab suppression bound', () => { + it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => { + const server = new AgentHookServer() + const internals = server as unknown as { + markTabClosedForAgentStatus: (tabId: string) => void + closedAgentStatusTabIds: Set + } + + const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200 + for (let i = 0; i < total; i += 1) { + internals.markTabClosedForAgentStatus(`closed-tab-${i}`) + } + + // Set stays bounded; oldest ids are evicted, most-recent are retained. + expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) + expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) + expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d4cc443d8ab..167331765cf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // not resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +// Why: closed-tab suppression only needs to cover recently closed tabs — a +// status event for a long-closed tab cannot arrive once its process/hooks are +// gone. Bound the set so it can't grow one entry per tab close for the whole +// session (it is otherwise only cleared at app quit). +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 + type LastStatusFile = { version: number entries: Record @@ -596,7 +602,17 @@ export class AgentHookServer { } private markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction only + // sheds the oldest ids, which can no longer receive status events. + this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } } private shouldSuppressClosedTabStatus(paneKey: string): boolean { From 39dbab41d8b3bf788c306385dc4743411e4b7a94 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:44:58 -0700 Subject: [PATCH 08/46] perf(renderer): purge three orphaned tab/worktree module maps on removal (#7562) Co-authored-by: Orca --- .../src/lib/agent-startup-delayed-delivery.ts | 24 +-- .../src/lib/agent-startup-delivery-guards.ts | 48 ++++++ .../src/lib/foreground-terminal-tabs.ts | 9 + ...tab-worktree-orphan-map-purge-leak.test.ts | 157 ++++++++++++++++++ src/renderer/src/store/slices/terminals.ts | 6 + src/renderer/src/store/slices/worktrees.ts | 29 ++++ 6 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/lib/agent-startup-delivery-guards.ts create mode 100644 src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 9512b267cf4..5ca13944b32 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -1,6 +1,13 @@ import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { + agentStartupDeliveryKey as deliveryKey, + clearConsumedAgentStartupDeliveriesForTests, + isAgentStartupDeliveryConsumed, + markAgentStartupDeliveryConsumed, + releaseAgentStartupDeliveryConsumed +} from './agent-startup-delivery-guards' type AppStoreSnapshot = ReturnType @@ -13,7 +20,6 @@ type PendingAgentStartupDelivery = { } const pendingAgentStartupDeliveries = new Map() -const consumedAgentStartupDeliveries = new Set() const staleStartupRecheckTimers = new Map>() let unsubscribePendingAgentStartupDeliveries: (() => void) | null = null @@ -99,7 +105,7 @@ function stopPendingAgentStartupSubscriptionIfIdle(): void { export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDelivery): void { const key = deliveryKey(delivery) - if (consumedAgentStartupDeliveries.has(key)) { + if (isAgentStartupDeliveryConsumed(key)) { return } pendingAgentStartupDeliveries.set(key, delivery) @@ -109,7 +115,7 @@ export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDe export function resetAgentStartupDelayedDeliveryForTests(): void { pendingAgentStartupDeliveries.clear() - consumedAgentStartupDeliveries.clear() + clearConsumedAgentStartupDeliveriesForTests() for (const timer of staleStartupRecheckTimers.values()) { globalThis.clearTimeout(timer) } @@ -124,10 +130,10 @@ export function beginAgentStartupDeliveryAttempt(args: { launchToken: string }): boolean { const key = deliveryKey(args) - if (consumedAgentStartupDeliveries.has(key)) { + if (isAgentStartupDeliveryConsumed(key)) { return false } - consumedAgentStartupDeliveries.add(key) + markAgentStartupDeliveryConsumed(key) pendingAgentStartupDeliveries.delete(key) clearStaleStartupRecheck(key) return true @@ -138,13 +144,7 @@ export function releaseAgentStartupDeliveryAttempt(args: { tabId: string launchToken: string }): void { - consumedAgentStartupDeliveries.delete(deliveryKey(args)) -} - -function deliveryKey( - delivery: Pick -): string { - return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` + releaseAgentStartupDeliveryConsumed(deliveryKey(args)) } function flushPendingAgentStartupDeliveries(): void { diff --git a/src/renderer/src/lib/agent-startup-delivery-guards.ts b/src/renderer/src/lib/agent-startup-delivery-guards.ts new file mode 100644 index 00000000000..5e1f4d71d14 --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delivery-guards.ts @@ -0,0 +1,48 @@ +// Leaf module (imports nothing from the store) holding the consumed +// agent-startup-delivery guard set, so store slices can purge it on tab/worktree +// removal without creating a store → agent-startup-delayed-delivery → store +// import cycle. + +export function agentStartupDeliveryKey(delivery: { + worktreeId: string + tabId: string + launchToken: string +}): string { + return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` +} + +const consumedAgentStartupDeliveries = new Set() + +export function isAgentStartupDeliveryConsumed(key: string): boolean { + return consumedAgentStartupDeliveries.has(key) +} + +export function markAgentStartupDeliveryConsumed(key: string): void { + consumedAgentStartupDeliveries.add(key) +} + +export function releaseAgentStartupDeliveryConsumed(key: string): void { + consumedAgentStartupDeliveries.delete(key) +} + +export function clearConsumedAgentStartupDeliveriesForTests(): void { + consumedAgentStartupDeliveries.clear() +} + +// Why: one consumed guard is recorded per launch that reached delivery and is +// never released on the happy path, so guards for removed tabs/worktrees would +// accumulate for the renderer's whole session. Drop them when the owning tab is +// closed or its worktree is removed. +export function forgetAgentStartupDeliveriesForTabs(tabIds: Iterable): void { + const tabIdSet = tabIds instanceof Set ? tabIds : new Set(tabIds) + if (tabIdSet.size === 0) { + return + } + for (const key of consumedAgentStartupDeliveries) { + // agentStartupDeliveryKey() is `${worktreeId}\0${tabId}\0${launchToken}`. + const tabId = key.split('\0')[1] ?? '' + if (tabIdSet.has(tabId)) { + consumedAgentStartupDeliveries.delete(key) + } + } +} diff --git a/src/renderer/src/lib/foreground-terminal-tabs.ts b/src/renderer/src/lib/foreground-terminal-tabs.ts index 7824598db26..925be716ccb 100644 --- a/src/renderer/src/lib/foreground-terminal-tabs.ts +++ b/src/renderer/src/lib/foreground-terminal-tabs.ts @@ -55,6 +55,15 @@ export function getForegroundTerminalTabLastSeenAtById(): Record return Object.fromEntries(foregroundTerminalTabLastSeenAtById) } +// Why: retired terminal tab ids never recur, so their last-seen timestamps would +// accumulate for the renderer's whole session. Drop them when a tab is closed or +// its worktree is removed (mirrors forgetAgentHibernationTabOutput). +export function forgetForegroundTerminalTabs(tabIds: Iterable): void { + for (const tabId of tabIds) { + foregroundTerminalTabLastSeenAtById.delete(tabId) + } +} + export function resetForegroundTerminalTabIdsForTests(): void { explicitForegroundTerminalTabIds = new Set() visibleTerminalTabClaimsByToken.clear() diff --git a/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts new file mode 100644 index 00000000000..f34c6627eb0 --- /dev/null +++ b/src/renderer/src/store/slices/tab-worktree-orphan-map-purge-leak.test.ts @@ -0,0 +1,157 @@ +/** + * Memory-leak regression: three module-level maps keyed by tab/worktree id grew + * monotonically over a session because no removal path evicted their entries. + * + * - `detachedHeadAutoDerivedDisplayNames` (worktrees.ts) — worktree-keyed; only + * self-evicted when a branch reappeared, so a worktree removed while detached + * leaked its entry. + * - `foregroundTerminalTabLastSeenAtById` (lib/foreground-terminal-tabs) — + * tab-keyed; gained an entry per foregrounded tab, never dropped on close. + * - `consumedAgentStartupDeliveries` (lib/agent-startup-delivery-guards) — + * (worktreeId,tabId,launchToken)-keyed; one permanent guard per delivery, + * only released on the retry path, so removed tabs/worktrees leaked it. + * + * worktreeId and tabId are unbounded, ephemeral key spaces (fresh ids, never reused). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { + getForegroundTerminalTabLastSeenAtById, + resetForegroundTerminalTabIdsForTests, + setForegroundTerminalTabIds +} from '@/lib/foreground-terminal-tabs' +import { + beginAgentStartupDeliveryAttempt, + resetAgentStartupDelayedDeliveryForTests +} from '@/lib/agent-startup-delayed-delivery' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' +import { + getDetachedHeadAutoDerivedDisplayNameForTests, + setDetachedHeadAutoDerivedDisplayNameForTests +} from './worktrees' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' +const TOKEN1 = 'launch-1' +const TOKEN2 = 'launch-2' + +function seedMaps(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })] + } + }) + setDetachedHeadAutoDerivedDisplayNameForTests(WT1, 'wt1 (a1b2c3d)') + setDetachedHeadAutoDerivedDisplayNameForTests(WT2, 'wt2 (e4f5g6h)') + // Records a last-seen timestamp for both tabs. + setForegroundTerminalTabIds([TAB1, TAB2]) + // Consumes a startup delivery guard for each tab. + expect( + beginAgentStartupDeliveryAttempt({ worktreeId: WT1, tabId: TAB1, launchToken: TOKEN1 }) + ).toBe(true) + expect( + beginAgentStartupDeliveryAttempt({ worktreeId: WT2, tabId: TAB2, launchToken: TOKEN2 }) + ).toBe(true) +} + +// A second begin returns false while the guard is still present, true once it is +// cleared — so this observes the consumed-set without reaching into internals. +function startupGuardCleared(worktreeId: string, tabId: string, launchToken: string): boolean { + return beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken }) +} + +describe('tab/worktree removal evicts the module maps it previously leaked', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.remove.mockResolvedValue(undefined) + resetForegroundTerminalTabIdsForTests() + resetAgentStartupDelayedDeliveryForTests() + }) + + afterEach(() => { + resetForegroundTerminalTabIdsForTests() + resetAgentStartupDelayedDeliveryForTests() + }) + + it('bulk purgeWorktreeTerminalState drops the removed worktree/tab entries only', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + + // Removed worktree/tab: all three maps evicted. + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT1)).toBeUndefined() + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + // Surviving worktree/tab: retained (guard over-eviction). + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT2)).toBe('wt2 (e4f5g6h)') + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) + + it('single removeWorktree drops the removed worktree/tab entries only', async () => { + const store = createTestStore() + seedMaps(store) + + const result = await store.getState().removeWorktree(WT1) + expect(result).toEqual({ ok: true }) + + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT1)).toBeUndefined() + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + expect(getDetachedHeadAutoDerivedDisplayNameForTests(WT2)).toBe('wt2 (e4f5g6h)') + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) + + it('closeTab drops the closed tab foreground + startup entries only', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().closeTab(TAB1) + + // closeTab is tab-scoped: the tab-keyed maps drop, the worktree-keyed one stays. + expect(getForegroundTerminalTabLastSeenAtById()[TAB1]).toBeUndefined() + expect(startupGuardCleared(WT1, TAB1, TOKEN1)).toBe(true) + expect(getForegroundTerminalTabLastSeenAtById()[TAB2]).toBeGreaterThan(0) + expect(startupGuardCleared(WT2, TAB2, TOKEN2)).toBe(false) + }) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 9cf68efd612..a00aa3e54b3 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -39,6 +39,8 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shel import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' import { isClaudeAgent } from '@/lib/agent-status' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' @@ -1338,6 +1340,10 @@ export const createTerminalSlice: StateCreator // a fresh leafId at epoch 0), so drop the panes' hibernation output epochs to // keep that module-level map from growing for the renderer's whole lifetime. forgetAgentHibernationTabOutput(tabId) + // Why: same rationale for the tab's foreground last-seen timestamp and any + // consumed agent-startup delivery guards — retired tab ids never recur. + forgetForegroundTerminalTabs([tabId]) + forgetAgentStartupDeliveriesForTabs([tabId]) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { const workspaceItem = tabs.find( (entry) => entry.contentType === 'terminal' && entry.entityId === tabId diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 248bde1eb6f..077deec381e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -42,6 +42,8 @@ import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' @@ -1451,6 +1453,19 @@ export function resetHostedReviewLinkMutationGenerationForTests(): void { hostedReviewLinkWorktreeIdAliases.clear() } +export function setDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string, + displayName: string +): void { + detachedHeadAutoDerivedDisplayNames.set(worktreeId, displayName) +} + +export function getDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string +): string | undefined { + return detachedHeadAutoDerivedDisplayNames.get(worktreeId) +} + function hostedReviewLinksAreCleared(worktree: Worktree): boolean { return HOSTED_REVIEW_LINK_KEYS.every((key) => worktree[key] == null) && !worktree.pushTarget } @@ -1828,7 +1843,14 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial const tabs = get().tabsByWorktree[worktreeId] ?? [] const tabIds = new Set(tabs.map((t) => t.id)) + // Why: this path deletes tabsByWorktree wholesale (not via closeTab), so + // purge the module-level maps keyed by this worktree/its tabs here too — + // matching buildWorktreePurgeState so retired ids don't accumulate. + detachedHeadAutoDerivedDisplayNames.delete(worktreeId) + forgetForegroundTerminalTabs(tabIds) + forgetAgentStartupDeliveriesForTabs(tabIds) + // Why: deletion is async (backend + terminal/browser teardown awaited // above), so snapshot the sidebar's current top-row anchor in the same // tick we remove the row. Recording at click time goes stale across the From e82eb82716a284193ca845abbb42587959764eb0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:45:29 -0700 Subject: [PATCH 09/46] perf(renderer): purge sparse-preset maps when a repo is removed (#7564) Co-authored-by: Orca --- src/renderer/src/store/slices/repos.ts | 15 +++- ...se-presets-repo-removal-purge-leak.test.ts | 70 +++++++++++++++++++ .../src/store/slices/sparse-presets.ts | 52 ++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index c418bb5fa1b..da0cb011d56 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -49,6 +49,7 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { omitSparsePresetsForRepos } from './sparse-presets' import { findRepoForHost, getRepoHostIdentity, @@ -2404,7 +2405,14 @@ export const createRepoSlice: StateCreator = (set, repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) : s.projects - return { repos, projects, projectHostSetups } + const survivingRepoIds = new Set(repos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) + return { + repos, + projects, + projectHostSetups, + ...omitSparsePresetsForRepos(s, removedRepoIds) + } }) return { ...result, repo } } catch (err) { @@ -2669,8 +2677,13 @@ export const createRepoSlice: StateCreator = (set, delete nextLastVisitedAtByWorktreeId[id] } } + const survivingRepoIds = new Set(nextRepos.map((r) => r.id)) + const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) return { repos: nextRepos, + // Why: drop the removed repos' sparse-preset maps so they don't outlive + // the repo for the renderer's whole session. + ...omitSparsePresetsForRepos(s, removedRepoIds), ...mergeProjectCompatibilityForHostRepoChange({ previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, nextRepos, diff --git a/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts new file mode 100644 index 00000000000..d03b8418345 --- /dev/null +++ b/src/renderer/src/store/slices/sparse-presets-repo-removal-purge-leak.test.ts @@ -0,0 +1,70 @@ +/** + * Memory-leak regression: removing a repo must purge its sparse-preset maps. + * + * The four per-repo maps (`sparsePresetsByRepo`, `sparsePresetsLoadingByRepo`, + * `sparsePresetsLoadStatusByRepo`, `sparsePresetsErrorByRepo`) are populated + * lazily by `fetchSparsePresets` but were never pruned when a repo was removed, + * so orphaned entries accumulated for the renderer's whole session. repoIds are + * random UUIDs (never reused), so re-adding a repo cannot reclaim the old keys. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Repo, SparsePreset } from '../../../../shared/types' + +const repo1: Repo = { id: 'repo-1', path: '/r1', displayName: 'R1', badgeColor: '#000', addedAt: 1 } +const repo2: Repo = { id: 'repo-2', path: '/r2', displayName: 'R2', badgeColor: '#111', addedAt: 2 } + +const reposRemove = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + reposRemove.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove }, + pty: { kill: vi.fn() }, + runtimeEnvironments: { call: vi.fn() } + } + }) +}) + +function preset(id: string, repoId: string): SparsePreset { + return { id, repoId, name: id, directories: ['src'], createdAt: 1, updatedAt: 1 } +} + +function seed(store: ReturnType): void { + store.setState({ + repos: [repo1, repo2], + worktreesByRepo: { + [repo1.id]: [makeWorktree({ id: 'repo-1::/r1/wt1', repoId: repo1.id, path: '/r1/wt1' })], + [repo2.id]: [makeWorktree({ id: 'repo-2::/r2/wt1', repoId: repo2.id, path: '/r2/wt1' })] + }, + sparsePresetsByRepo: { + [repo1.id]: [preset('p1', repo1.id)], + [repo2.id]: [preset('p2', repo2.id)] + }, + sparsePresetsLoadingByRepo: { [repo1.id]: false, [repo2.id]: false }, + sparsePresetsLoadStatusByRepo: { [repo1.id]: 'loaded', [repo2.id]: 'loaded' }, + sparsePresetsErrorByRepo: { [repo1.id]: 'stale', [repo2.id]: 'boom' } + }) +} + +describe('removeProject purges the removed repo sparse-preset maps (leak regression)', () => { + it('drops all four sparse-preset maps for the removed repo, keeps the surviving repo', async () => { + const store = createTestStore() + seed(store) + + await store.getState().removeProject(repo1.id) + + const s = store.getState() + // Removed repo: every sparse-preset map key is gone. + expect(repo1.id in s.sparsePresetsByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsLoadingByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsLoadStatusByRepo).toBe(false) + expect(repo1.id in s.sparsePresetsErrorByRepo).toBe(false) + // Surviving repo: retained (guard over-eviction). + expect(s.sparsePresetsByRepo[repo2.id]).toEqual([preset('p2', repo2.id)]) + expect(s.sparsePresetsLoadingByRepo[repo2.id]).toBe(false) + expect(s.sparsePresetsLoadStatusByRepo[repo2.id]).toBe('loaded') + expect(s.sparsePresetsErrorByRepo[repo2.id]).toBe('boom') + }) +}) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index 5a111cad84d..af118c7703b 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -26,6 +26,58 @@ export type SparsePresetsSlice = { removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise } +type SparsePresetsMaps = Pick< + AppState, + | 'sparsePresetsByRepo' + | 'sparsePresetsLoadingByRepo' + | 'sparsePresetsLoadStatusByRepo' + | 'sparsePresetsErrorByRepo' +> + +// Why: the four per-repo sparse-preset maps are populated lazily per repo but +// were never pruned when a repo was removed, so orphaned entries accumulated for +// the renderer's whole session. Call this from the repo-removal reducers to drop +// entries for repos that no longer exist. Returns only the maps that changed so +// unrelated selectors don't re-run. +export function omitSparsePresetsForRepos( + s: SparsePresetsMaps, + removedRepoIds: Iterable +): Partial { + const removed = removedRepoIds instanceof Set ? removedRepoIds : new Set(removedRepoIds) + if (removed.size === 0) { + return {} + } + const omit = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const id of removed) { + if (id in out) { + delete out[id] + changed = true + } + } + return changed ? out : obj + } + const result: Partial = {} + const byRepo = omit(s.sparsePresetsByRepo) + if (byRepo !== s.sparsePresetsByRepo) { + result.sparsePresetsByRepo = byRepo + } + const loading = omit(s.sparsePresetsLoadingByRepo) + if (loading !== s.sparsePresetsLoadingByRepo) { + result.sparsePresetsLoadingByRepo = loading + } + const status = omit(s.sparsePresetsLoadStatusByRepo) + if (status !== s.sparsePresetsLoadStatusByRepo) { + result.sparsePresetsLoadStatusByRepo = status + } + const error = omit(s.sparsePresetsErrorByRepo) + if (error !== s.sparsePresetsErrorByRepo) { + result.sparsePresetsErrorByRepo = error + } + return result +} + export const createSparsePresetsSlice: StateCreator = ( set, get From d39812f98d8a0ca4670e6a0cf625a9c5cacc08f7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:46:04 -0700 Subject: [PATCH 10/46] perf(renderer): LRU-bound the native-chat composer scope caches (#7566) Co-authored-by: Orca --- .../native-chat-composer-scope-cache.test.ts | 35 +++++++++++++++++++ .../native-chat-composer-scope-cache.ts | 24 +++++++++++++ .../native-chat-draft-cache.test.ts | 18 ++++++++++ .../native-chat/native-chat-draft-cache.ts | 5 ++- .../use-native-chat-composer-attachments.ts | 4 ++- 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts new file mode 100644 index 00000000000..07867e8a5c4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { + NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX, + setBoundedScopeCacheEntry +} from './native-chat-composer-scope-cache' + +describe('setBoundedScopeCacheEntry', () => { + it('bounds the cache with LRU eviction, keeping re-set keys', () => { + const cache = new Map() + setBoundedScopeCacheEntry(cache, 'keep', 1) + + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 20 + for (let i = 0; i < total; i += 1) { + setBoundedScopeCacheEntry(cache, `scope-${i}`, i) + if (i % 10 === 0) { + setBoundedScopeCacheEntry(cache, 'keep', 1) + } + } + + expect(cache.size).toBe(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) + expect(cache.has('scope-0')).toBe(false) // oldest untouched entry evicted + expect(cache.has('keep')).toBe(true) // periodically re-set → retained + expect(cache.has(`scope-${total - 1}`)).toBe(true) // most recent retained + }) + + it('moves a re-set key to most-recent and updates its value', () => { + const cache = new Map() + setBoundedScopeCacheEntry(cache, 'a', 1) + setBoundedScopeCacheEntry(cache, 'b', 2) + setBoundedScopeCacheEntry(cache, 'a', 3) + + expect([...cache.keys()]).toEqual(['b', 'a']) + expect(cache.get('a')).toBe(3) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts new file mode 100644 index 00000000000..7a63f16f9fe --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -0,0 +1,24 @@ +// Shared LRU bound for the native-chat composer's per-scope caches (draft text +// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The +// caches exist so an in-progress message survives the composer unmounting on a +// TUI/GUI toggle, but a scope key for a permanently-removed pane is never +// revisited, so without a bound its unsent entry would linger for the renderer's +// whole session. delete-then-set keeps the actively-edited scope most-recent so +// eviction only sheds the oldest untouched scopes. +export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128 + +export function setBoundedScopeCacheEntry( + cache: Map, + scopeKey: string, + value: T +): void { + cache.delete(scopeKey) + cache.set(scopeKey, value) + while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts index c12d661e39d..7aa0f658517 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts @@ -4,6 +4,7 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' afterEach(() => { clearNativeChatDraftCacheForTests() @@ -26,4 +27,21 @@ describe('native-chat draft cache', () => { writeNativeChatDraftCache('pty-1', '') expect(readNativeChatDraftCache('pty-1')).toBe('') }) + + it('bounds the cache so unsent drafts for removed panes cannot accumulate', () => { + writeNativeChatDraftCache('keep', 'hot') + + const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 40 + for (let i = 0; i < total; i += 1) { + writeNativeChatDraftCache(`scope-${i}`, `draft-${i}`) + if (i % 20 === 0) { + writeNativeChatDraftCache('keep', 'hot') + } + } + + // Oldest untouched draft evicted; the actively-edited and most-recent survive. + expect(readNativeChatDraftCache('scope-0')).toBe('') + expect(readNativeChatDraftCache('keep')).toBe('hot') + expect(readNativeChatDraftCache(`scope-${total - 1}`)).toBe(`draft-${total - 1}`) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 2c230c0a4eb..2a753744458 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -4,6 +4,8 @@ // the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors // the attachment cache so both halves of an unsent message survive the toggle. +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + const draftCache = new Map() export function readNativeChatDraftCache(scopeKey: string): string { @@ -17,7 +19,8 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void draftCache.delete(scopeKey) return } - draftCache.set(scopeKey, draft) + // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(draftCache, scopeKey, draft) } export function clearNativeChatDraftCacheForTests(): void { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 926e9a2ea4d..e64512fc98d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -7,6 +7,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -153,7 +154,8 @@ function writeNativeChatAttachmentCache( attachmentCache.delete(scopeKey) return } - attachmentCache.set(scopeKey, [...attachments]) + // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. + setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments]) } export function clearNativeChatAttachmentCacheForTests(): void { From 459b8ecbeed6f8e5519e93a6fb32148757049cb8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:48:45 -0700 Subject: [PATCH 11/46] perf(renderer): gate CodexRestartChip's store subscriptions on restart-notice existence (#7560) Co-authored-by: Orca --- .../src/components/CodexRestartChip.tsx | 12 +++- .../codex-restart-chip-inputs.test.ts | 58 +++++++++++++++++++ .../components/codex-restart-chip-inputs.ts | 37 ++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/codex-restart-chip-inputs.test.ts create mode 100644 src/renderer/src/components/codex-restart-chip-inputs.ts diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index fa644f79df5..960703e8759 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAppStore } from '../store' +import { selectCodexRestartInputs } from './codex-restart-chip-inputs' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' import { @@ -73,8 +75,14 @@ export default function CodexRestartChip({ worktreeId: string }): React.JSX.Element | null { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId) + // Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId + // on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on + // pty teardown), so subscribe to them only while a restart notice actually + // exists. Otherwise this per-worktree chip re-rendered on every pty event to + // compute "no notice → render nothing". See codex-restart-chip-inputs. + const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore( + useShallow(selectCodexRestartInputs) + ) const staleWorktreePtyIds = useMemo( () => collectStalePtyIdsForTabs({ diff --git a/src/renderer/src/components/codex-restart-chip-inputs.test.ts b/src/renderer/src/components/codex-restart-chip-inputs.test.ts new file mode 100644 index 00000000000..ad6412efd16 --- /dev/null +++ b/src/renderer/src/components/codex-restart-chip-inputs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import { + EMPTY_CODEX_RESTART_INPUTS, + selectCodexRestartInputs, + type CodexRestartInputsState +} from './codex-restart-chip-inputs' + +describe('selectCodexRestartInputs', () => { + it('returns the frozen empty bundle while no restart notice exists', () => { + const state: CodexRestartInputsState = { + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + codexRestartNoticeByPtyId: {} + } + expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS) + + // Churning EITHER map while no notice exists must NOT change the selected + // reference, so a useShallow subscription skips the re-render. This covers + // the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty. + const churnedPty: CodexRestartInputsState = { + ...state, + ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] } + } + const churnedNotice: CodexRestartInputsState = { + ptyIdsByTabId: state.ptyIdsByTabId, + codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread + } + expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS) + expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS) + expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe( + true + ) + }) + + it('exposes both live maps the instant a restart notice exists', () => { + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const codexRestartNoticeByPtyId = { 'pty-1': {} as never } + const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId } + const selected = selectCodexRestartInputs(state) + // Live references pass straight through so the stale-pty memo + notice lookup derive fully. + expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId) + expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId) + expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS) + }) + + it('shallow-changes only when a live map reference changes while a notice exists', () => { + const ptyIdsByTabId = { 'tab-1': ['pty-1'] } + const s1: CodexRestartInputsState = { + ptyIdsByTabId, + codexRestartNoticeByPtyId: { 'pty-1': {} as never } + } + const r1 = selectCodexRestartInputs(s1) + expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true) + + const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } } + expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/codex-restart-chip-inputs.ts b/src/renderer/src/components/codex-restart-chip-inputs.ts new file mode 100644 index 00000000000..eec46f6a8e7 --- /dev/null +++ b/src/renderer/src/components/codex-restart-chip-inputs.ts @@ -0,0 +1,37 @@ +import type { AppState } from '@/store/types' + +export type CodexRestartInputsState = Pick + +export type CodexRestartInputs = CodexRestartInputsState + +// Why: shared frozen bundle returned while no Codex restart notice exists — the +// overwhelmingly common case. CodexRestartChip is mounted once per worktree +// Terminal (visible AND hidden-measurable) and once per split group, and reads +// these two maps only to find panes carrying a restart notice, which appear only +// on a Codex account switch. BOTH churn on unrelated pty lifecycle: +// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and +// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the +// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either +// re-rendered every mounted chip on that churn. Gate both behind notice-existence +// so idle chips keep the same reference and stop reacting; behavior is identical +// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be +// mutated. +export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({ + ptyIdsByTabId: {}, + codexRestartNoticeByPtyId: {} +}) + +/** + * Expose the pty and restart-notice maps to the chip only while at least one + * Codex restart notice is live; otherwise return a stable frozen bundle so a + * `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn. + */ +export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs { + if (Object.keys(s.codexRestartNoticeByPtyId).length === 0) { + return EMPTY_CODEX_RESTART_INPUTS + } + return { + ptyIdsByTabId: s.ptyIdsByTabId, + codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId + } +} From f5d1a9d5496eee116b789bbd09640ad08472847e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:57:54 -0700 Subject: [PATCH 12/46] fix(agent-status): cap retainedAgentsByPaneKey to stop renderer heap OOM (#7528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retainedAgentsByPaneKey snapshots a completed agent (a full AgentStatusEntry — up to ~24KB of prompt/message text — plus a TerminalTab) per ephemeral paneKey. paneKeys never recur, and the map is pruned only on worktree removal or manual dismissal, so a long-lived worktree in a busy multi-agent / orchestration session grows it without bound. This is the dominant large-payload driver of the renderer JS-heap OOM seen in the Windows crash bundles (heap climbing to the 3586 MB V8 old-space limit under continuous multi-agent work). Cap the map with insertion-order (== retention-order) FIFO eviction, mirroring capRecordByInsertionOrder in github.ts: the newest completions survive; the oldest simply stop showing in the recently-completed overlay. retainAgents is the only path that grows the map, so capping there fully bounds it. Adds a leak regression test that drives the production retainAgents path with distinct ephemeral paneKeys and asserts the map stays bounded — fails before this change (grows to N), passes after (capped at MAX_RETAINED_AGENTS). Co-authored-by: Neil --- .../slices/agent-status-retained-leak.test.ts | 126 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 31 ++++- 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/agent-status-retained-leak.test.ts diff --git a/src/renderer/src/store/slices/agent-status-retained-leak.test.ts b/src/renderer/src/store/slices/agent-status-retained-leak.test.ts new file mode 100644 index 00000000000..2259af3cf5c --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-retained-leak.test.ts @@ -0,0 +1,126 @@ +/** + * Memory-leak regression: retainedAgentsByPaneKey must stay bounded. + * + * `retainedAgentsByPaneKey` is a Record keyed by ephemeral paneKey + * (`${tabId}:${leafId}`, where leafId is a fresh UUID minted per pane and never + * reused). Every agent that finishes and then vanishes from the live map is + * snapshotted here via `retainAgents`. Each snapshot is a full RetainedAgentEntry + * — an AgentStatusEntry (which carries an up-to-8KB lastAssistantMessage, an + * up-to-16KB interactivePrompt, a prompt, and up to 20 stateHistory rows) plus a + * full TerminalTab snapshot. + * + * Before the fix, `retainAgents` only ever wrote entries and never capped them, + * so the map grew monotonically with the number of distinct completed-agent + * paneKeys observed. The only removal paths are worktree removal + * (`pruneRetainedAgents`) and explicit user dismissal — neither of which runs + * while a long-lived worktree stays open. Under a multi-hour multi-agent / + * orchestration session (sub-agents complete continuously), this large-payload + * accumulator is the dominant driver of the renderer JS-heap OOM seen in the + * Windows crash bundles (heap climbing to the 3586 MB old-space limit). + * + * The fix caps it to MAX_RETAINED_AGENTS, evicting the oldest-retained keys + * (insertion order == retention order, so the newest completions survive). + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import type { RetainedAgentEntry } from './agent-status' +import { createTestStore } from './store-test-helpers' + +// MAX_RETAINED_AGENTS is module-private; mirror its value here. +const MAX_RETAINED_AGENTS = 500 + +// Approximate the worst-case per-entry payload the production type permits, so +// the leak's byte weight (not just entry count) is visible in the assertions. +const BIG_ASSISTANT_MESSAGE = 'a'.repeat(8 * 1024) +const BIG_INTERACTIVE_PROMPT = 'q'.repeat(16 * 1024) + +function makeRetained(index: number, worktreeId = 'wt-x'): RetainedAgentEntry { + const paneKey = `tab-${index}:leaf-${index}` + const entry: AgentStatusEntry = { + state: 'done', + prompt: `prompt ${index}`, + updatedAt: index, + stateStartedAt: index, + paneKey, + stateHistory: [], + lastAssistantMessage: BIG_ASSISTANT_MESSAGE, + interactivePrompt: BIG_INTERACTIVE_PROMPT + } + return { + entry, + worktreeId, + tab: { id: `tab-${index}`, title: 'claude' } as unknown as TerminalTab, + agentType: 'claude', + startedAt: index + } +} + +describe('retainedAgentsByPaneKey stays bounded (leak regression)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('caps retainedAgentsByPaneKey and keeps the most recently retained keys', () => { + const store = createTestStore() + + // Drive the production retention path with more distinct ephemeral paneKeys + // than the cap allows — one call per completion, as production does. + const total = MAX_RETAINED_AGENTS + 200 + for (let i = 0; i < total; i++) { + store.getState().retainAgents([makeRetained(i)]) + } + + const retained = store.getState().retainedAgentsByPaneKey + // Bounded — not `total`. Without the cap this is MAX_RETAINED_AGENTS + 200. + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + + // The newest completions survive; the oldest are evicted. + expect(retained[`tab-${total - 1}:leaf-${total - 1}`]).toBeDefined() + expect(retained['tab-0:leaf-0']).toBeUndefined() + // The exact eviction boundary: everything before (total - cap) is gone. + expect( + retained[`tab-${total - MAX_RETAINED_AGENTS - 1}:leaf-${total - MAX_RETAINED_AGENTS - 1}`] + ).toBeUndefined() + expect( + retained[`tab-${total - MAX_RETAINED_AGENTS}:leaf-${total - MAX_RETAINED_AGENTS}`] + ).toBeDefined() + }) + + it('caps even when many completions are retained in a single batch', () => { + const store = createTestStore() + const total = MAX_RETAINED_AGENTS + 50 + const batch = Array.from({ length: total }, (_, i) => makeRetained(i)) + + store.getState().retainAgents(batch) + + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + expect(retained[`tab-${total - 1}:leaf-${total - 1}`]).toBeDefined() + expect(retained['tab-0:leaf-0']).toBeUndefined() + }) + + it('does not evict anything while under the cap', () => { + const store = createTestStore() + for (let i = 0; i < MAX_RETAINED_AGENTS; i++) { + store.getState().retainAgents([makeRetained(i)]) + } + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(MAX_RETAINED_AGENTS) + expect(retained['tab-0:leaf-0']).toBeDefined() + }) + + it('re-retaining an existing paneKey overwrites in place and never grows the count', () => { + const store = createTestStore() + const first = makeRetained(0) + store.getState().retainAgents([first]) + // Same paneKey, fresh snapshot (e.g. a later status update for the same pane). + const updated = makeRetained(0) + updated.entry.prompt = 'updated' + store.getState().retainAgents([updated]) + + const retained = store.getState().retainedAgentsByPaneKey + expect(Object.keys(retained)).toHaveLength(1) + expect(retained['tab-0:leaf-0'].entry.prompt).toBe('updated') + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 855790fe43b..1ec81165c74 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -215,6 +215,32 @@ export type AgentStatusSlice = { clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void } +// Why: retainedAgentsByPaneKey snapshots a completed agent (a full +// AgentStatusEntry — up to ~24KB of prompt/message text — plus a TerminalTab) +// per ephemeral paneKey. paneKeys never recur, and the map is pruned only on +// worktree removal or manual dismissal, so a long-lived worktree in a busy +// multi-agent session grows it without bound — the dominant driver of the +// renderer JS-heap OOM. Cap by insertion order (== retention order), evicting +// the oldest completions first so the newest — the ones a user is most likely +// to still care about — always survive. Evicted rows just stop showing in the +// recently-completed overlay. +const MAX_RETAINED_AGENTS = 500 + +function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { for (const prefix of tabPrefixes) { if (paneKey.startsWith(prefix)) { @@ -2147,7 +2173,10 @@ export const createAgentStatusSlice: StateCreator Date: Mon, 6 Jul 2026 17:03:44 -0700 Subject: [PATCH 13/46] perf(renderer): project TabBar's agent-status subscription to stable agent types (#7559) Co-authored-by: Orca --- .../tab-bar/TabBar.context-menu.test.ts | 6 ++ .../src/components/tab-bar/TabBar.tsx | 14 ++-- .../TabBar.windows-shell-launch.test.ts | 6 ++ .../tab-bar/tab-agent-types-by-tab-id.test.ts | 66 +++++++++++++++++++ .../tab-bar/tab-agent-types-by-tab-id.ts | 41 ++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 03e85f8f0e5..d580d2c8c4c 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -59,6 +59,12 @@ vi.mock('react', async () => { } }) +// The headless React mock above stubs hooks, so zustand's useShallow (which +// calls useRef) has no dispatcher; make it a pass-through like the store mock. +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0e3f79e600e..e5e9c328a6e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -4,6 +4,7 @@ * branches share little beyond drag data, so consolidating them would cost * more clarity than the ~5 lines of bloat is worth. */ import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { SortableContext } from '@dnd-kit/sortable' import { ChevronLeft, @@ -78,7 +79,7 @@ import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' +import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') @@ -454,7 +455,13 @@ function TabBarInner({ // eligible when it launched an agent or has a live agent-status entry on any of // its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate. const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + // Why: the strip only needs each tab's stable agent identity, but the whole + // agentStatusByPaneKey map churns on every working↔idle transition app-wide. + // Select a shallow-stable { tabId: agentType } projection so the strip + // re-renders only when a tab gains/loses/changes its agent, not on status flips. + const tabAgentTypesByTabId = useAppStore( + useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) + ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) // Why: Electron elements run in a separate process, so clicking @@ -1108,8 +1115,7 @@ function TabBarInner({ // Key the live-agent lookup by the backing terminal tab id — // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. - const detectedAgent = - findTabAgentEntry(agentStatusByPaneKey ?? {}, terminalTab.id)?.agentType ?? null + const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null const canToggleViewMode = unifiedTabForItem !== undefined && canToggleNativeChat({ diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 1fa6e5809a8..9805727fe29 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -123,6 +123,12 @@ vi.mock('react', async () => { } }) +// The headless React mock above stubs hooks, so zustand's useShallow (which +// calls useRef) has no dispatcher; make it a pass-through like the store mock. +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector +})) + vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts new file mode 100644 index 00000000000..75ac3995670 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' +import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' + +function entry(partial: Partial): AgentStatusEntry { + return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry +} + +describe('selectTabAgentTypesByTabId', () => { + it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { + const map: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-1:leaf-b': entry({ agentType: 'codex' }), + 'tab-2:leaf-a': entry({ agentType: 'codex' }) + } + const projection = selectTabAgentTypesByTabId(map) + expect(projection).toEqual({ 'tab-1': 'claude', 'tab-2': 'codex' }) + + // Parity with the lookup it replaces, for every tab. + for (const tabId of ['tab-1', 'tab-2', 'tab-missing']) { + expect(projection[tabId] ?? null).toBe(findTabAgentEntry(map, tabId)?.agentType ?? null) + } + }) + + it('a first pane without an agentType yields null even if a later pane has one', () => { + const map: Record = { + 'tab-1:leaf-a': entry({ agentType: undefined }), + 'tab-1:leaf-b': entry({ agentType: 'claude' }) + } + // First matching pane wins (claims the tab) with no agentType -> null, exactly + // like findTabAgentEntry(...)?.agentType ?? null. + expect(selectTabAgentTypesByTabId(map)['tab-1'] ?? null).toBe( + findTabAgentEntry(map, 'tab-1')?.agentType ?? null + ) + }) + + it('stays shallow-equal across a working<->idle status flip (no re-render)', () => { + const working: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' }) + } + // A status write replaces the entry object but not the agentType. + const done: Record = { + 'tab-1:leaf-a': entry({ agentType: 'claude', state: 'done' }) + } + expect(shallow(selectTabAgentTypesByTabId(working), selectTabAgentTypesByTabId(done))).toBe( + true + ) + }) + + it('shallow-changes when a tab gains or changes its agent', () => { + const before = selectTabAgentTypesByTabId({ + 'tab-1:leaf-a': entry({ agentType: 'claude' }) + }) + const gained = selectTabAgentTypesByTabId({ + 'tab-1:leaf-a': entry({ agentType: 'claude' }), + 'tab-2:leaf-a': entry({ agentType: 'codex' }) + }) + expect(shallow(before, gained)).toBe(false) + }) + + it('ignores malformed pane keys with no tab id', () => { + expect(selectTabAgentTypesByTabId({ ':leaf-a': entry({ agentType: 'claude' }) })).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts new file mode 100644 index 00000000000..5fc8335a93c --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -0,0 +1,41 @@ +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' + +/** + * Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }` + * the tab strip actually reads (to gate the native-chat view-mode toggle). + * + * Why: agent-status pane keys are `${terminalTab.id}:${leafId}` and the tab strip + * only needs each tab's agent *identity* — which is fixed for the life of the + * agent. The full `agentStatusByPaneKey` map, however, gets a new top-level + * identity on every working↔idle status transition app-wide, so subscribing to it + * whole re-rendered every mounted tab strip on unrelated status churn. Selecting + * this projection under `useShallow` keeps the result referentially equal across + * those transitions, so the strip re-renders only when a tab actually gains, loses, + * or changes its agent. + * + * First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids + * are colon-free by construction, so the substring before the first `:` is the + * tab id). A pane whose entry has no `agentType` still claims the tab and yields + * `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`. + */ +export function selectTabAgentTypesByTabId( + agentStatusByPaneKey: Record +): Record { + const byTabId: Record = {} + const claimed = new Set() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + const colon = paneKey.indexOf(':') + if (colon <= 0) { + continue + } + const tabId = paneKey.slice(0, colon) + if (claimed.has(tabId)) { + continue + } + claimed.add(tabId) + if (entry.agentType != null) { + byTabId[tabId] = entry.agentType + } + } + return byTabId +} From 7c7ffd64add0da84c311fd1c6ae64ed0420787b7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:06:17 -0700 Subject: [PATCH 14/46] docs: update Android APK release link (#7609) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1043bc9d670..8e9bc05d3f3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) --- From 9bed9bbd34c039a219097de37eabfc139db28fc4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:10:04 -0700 Subject: [PATCH 15/46] perf(ssh): bound relay bulk-stream backlog so PTY echo is not head-of-line blocked (#7601) Co-authored-by: Orca --- .../ssh-typing-latency-under-relay-load.md | 115 +++++++ .../ssh-filesystem-provider-stream.test.ts | 3 +- src/main/ssh/relay-protocol.test.ts | 70 ++++- src/main/ssh/relay-protocol.ts | 113 +++++-- src/main/ssh/ssh-filesystem-stream-reader.ts | 8 +- src/relay/dispatcher.test.ts | 115 +++++++ src/relay/dispatcher.ts | 137 +++++++- src/relay/fs-handler-file-read.ts | 50 ++- src/relay/fs-handler-stream.test.ts | 58 ++++ src/relay/fs-handler.ts | 25 +- ...-pty-echo-backpressure.integration.test.ts | 295 ++++++++++++++++++ src/relay/fs-stream-registry.ts | 69 +++- src/relay/protocol.ts | 128 ++++++-- src/relay/relay.ts | 72 ++++- tests/e2e/ssh-docker-relay-perf.spec.ts | 98 ++++++ 15 files changed, 1285 insertions(+), 71 deletions(-) create mode 100644 docs/reference/ssh-typing-latency-under-relay-load.md create mode 100644 src/relay/fs-stream-pty-echo-backpressure.integration.test.ts diff --git a/docs/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md new file mode 100644 index 00000000000..f7ae8e0b3a7 --- /dev/null +++ b/docs/reference/ssh-typing-latency-under-relay-load.md @@ -0,0 +1,115 @@ +# SSH Typing Latency Under Relay Load + +Date: 2026-07-06 + +## Symptom + +Typing in an SSH-host terminal can become extremely slow (hundreds of ms to +seconds per echoed keystroke) while other SSH work is active — file previews, +source-control refresh, search. A quiet SSH shell stays snappy, which made the +reports look unreproducible. + +This is distinct from the local warm-switch lag documented in +`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()` +snapshot cost — already fixed; `TerminalHost.listSessions()` is now +metadata-only via `getAppliedSize()`). + +## Root Cause + +The relay and the Electron client share ONE ordered SSH channel for all +JSON-RPC traffic: PTY input/output, file streams, git responses, search, port +scans. Two mechanisms turned bulk traffic into typing latency: + +1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream` + pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB + framed) into the relay's stdout as fast as local disk reads completed, + ignoring the `write() === false` backpressure signal. A 10MB preview + enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted + mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds + of echo delay per open file. The reproduced measurement: 4,195,592 bytes + (the entire remaining 3MB test file, framed) queued ahead of one echo + frame. + +2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()` + in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did + `Buffer.concat([buffered, chunk])` per data event, re-copying the whole + backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, + ~80MB per 10MB file — on the Electron main thread, delaying `pty:write` + IPC dispatch and echo delivery to the renderer. + +## Fix + +- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`): + `fs.streamChunk` frames are serialized per client and each send waits for + the sink's `drain` when `write()` returns `false`. Interactive frames + (`pty.data`) still use plain `notify()` and are admitted immediately — + because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps + ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix + socket clients) surface `write()`'s boolean and a one-shot + `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose) + flushes parked waiters so a pump can never hang on a dead pipe. + +- **Credit-based flow control** (`fs.streamAck`): the client requests + streams with `flowControl: 'ack'` and acks each processed chunk; the relay + caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This + bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP + buffers) and paces the relay's base64/JSON encode loop so incoming + keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, + release, client detach, and a 1s staleness recheck. + +- **Cross-version compatibility**: old client + new relay → no + `flowControl` param → legacy unpaced pump (still drain-bounded, which is + transparent). New client + old relay → `fs.streamAck` is an unknown + notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is + intentionally unchanged — both sides bake it into chunk offset math, so + changing it would corrupt cross-version streams. + +- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list + buffering; each frame is assembled exactly once (one copy) instead of + re-concatenating the backlog per feed. + +## What Stays Fast / Unchanged + +- Relay PTY output batching (interactive echo fast path in + `src/relay/pty-handler.ts`) is untouched. +- No `listSessions()` / provider inventory was added anywhere near typing, + focus, switch, resume, resize, or render paths. +- Remote file streaming correctness: per-chunk length checks, chunk-count and + byte-count invariants, cancel paths, and the 12MB round-trip integration + test all pass unchanged. + +## Regression Coverage + +- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real + mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts + deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed + chunks when congested (pre-fix: whole file), ack window caps in-flight + chunks, legacy no-ack clients still get complete streams. +- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window, + resumes per ack, and releases its file handle when cancelled while parked. +- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating, + per-client targeting, dispose releases parked senders, interactive + notify() not gated behind a stalled bulk lane. +- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary + straddling, oversized-frame resync across odd chunk sizes, and a + no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape. +- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario: + types while two 8MB remote file-read loops and a git.status loop run; + budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix + HOL is only tens of ms — the in-process byte-bound test is the + authoritative red/green; the e2e guards the end-to-end wiring.) + +## Residual Gaps + +- **Large single-frame responses**: `git.*` responses (stdout capped at + `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results + are one atomic frame on the wire; a huge diff can still delay an echo by + its own transfer time. Fixing this requires response chunking (a protocol + change); bounded by the 16MB `MAX_MESSAGE_SIZE`. +- **Outbound direction**: a large `fs.writeFile` request frame (client → + relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`. + Same shape, opposite direction, rarer trigger (saving a large remote file + while typing). +- `pty.ackData` flow control for PTY output remains unenforced + (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are + already paced by the relay's 8ms batch/16KB slice scheduler. diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 29daf5af532..b66b5633503 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => { const result = await provider.readFile('/home/user/file.txt') expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', { - filePath: '/home/user/file.txt' + filePath: '/home/user/file.txt', + flowControl: 'ack' }) expect(result).toEqual({ content: text, isBinary: false }) }) diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 8369c3c02cd..c4e9f089743 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { HEADER_LENGTH, MessageType, @@ -160,6 +160,74 @@ describe('FrameDecoder', () => { expect(frames).toHaveLength(1) expect(frames[0].id).toBe(2) }) + + it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload')) + const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second')) + const combined = Buffer.concat([frame1, frame2]) + + for (let i = 0; i < combined.length; i += 1) { + decoder.feed(combined.subarray(i, i + 1)) + } + + expect(frames).toHaveLength(2) + expect(frames[0].payload.toString()).toBe('first payload') + expect(frames[1].id).toBe(2) + expect(frames[1].payload.toString()).toBe('second') + }) + + it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => { + const errors: Error[] = [] + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder( + (f) => frames.push(f), + (err) => errors.push(err) + ) + + const oversizedLength = 17 * 1024 * 1024 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1, 1) + header.writeUInt32BE(0, 5) + header.writeUInt32BE(oversizedLength, 9) + const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)]) + const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after')) + const combined = Buffer.concat([oversized, valid]) + + const chunkSize = 1024 * 1024 - 7 + for (let i = 0; i < combined.length; i += chunkSize) { + decoder.feed(combined.subarray(i, i + chunkSize)) + } + + expect(errors).toHaveLength(1) + expect(frames).toHaveLength(1) + expect(frames[0].payload.toString()).toBe('after') + }) + + it('never rebuilds the buffered stream per feed while assembling a large frame', () => { + // Regression: feed() used Buffer.concat([buffered, chunk]) per data event, + // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the + // Electron main thread while fs.streamChunk frames arrive (SSH typing lag). + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61)) + + const concatSpy = vi.spyOn(Buffer, 'concat') + try { + for (let i = 0; i < frame.length; i += 32 * 1024) { + decoder.feed(frame.subarray(i, i + 32 * 1024)) + } + } finally { + concatSpy.mockRestore() + } + + expect(frames).toHaveLength(1) + expect(frames[0].payload.length).toBe(512 * 1024) + expect(concatSpy).not.toHaveBeenCalled() + }) }) describe('parseJsonRpcMessage', () => { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 3b8b63af159..db57c4d19aa 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -127,7 +127,13 @@ export type DecodedFrame = { * Incremental frame parser. Feed it chunks of data; it emits complete frames. */ export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() runs on the Electron main thread for every SSH channel data + // event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies + // every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per + // large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk + // list assembles each frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -137,21 +143,31 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + // Why: throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse leftover payload // bytes as a new header, corrupting every future frame. Instead we // skip the entire oversized frame so the decoder stays synchronized. if (length > MAX_MESSAGE_SIZE) { - if (this.buffer.length < totalLength) { - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -159,24 +175,83 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 + } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count } } diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index cda490c8580..2147ed0ef4f 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -148,6 +148,10 @@ export async function readFileViaStream( expectedSeq += 1 receivedChunks += 1 bytesReceived += decoded.length + // Why: credit-based flow control — the relay caps unacked chunks so bulk + // stream frames cannot queue unbounded ahead of interactive pty.data + // frames on the shared SSH channel. Old relays ignore this notification. + mux.notify('fs.streamAck', { streamId: id, seq }) } const handleEnd = (params: Record): void => { @@ -260,7 +264,9 @@ export async function readFileViaStream( unsubscribers.push(onDispose) void mux - .request('fs.readFileStream', { filePath }) + // Why: flowControl declares this client acks each chunk, letting a new + // relay pace the pump. Old relays ignore the extra param and flood. + .request('fs.readFileStream', { filePath, flowControl: 'ack' }) .then((rawMetadata) => { if (settled) { return diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index a87e37c6078..5ab997c9d21 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -395,4 +395,119 @@ describe('RelayDispatcher', () => { expect(listener).toHaveBeenCalledWith(1) }) + + describe('notifyBulk (bulk lane backpressure)', () => { + it('resolves immediately when the sink accepts the frame', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + expect(frames).toHaveLength(1) + const frame = decodeFirstFrame(frames[0]) + const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification + expect(msg.method).toBe('bulk.event') + } finally { + bulkDispatcher.dispose() + } + }) + + it('holds the next bulk frame until the saturated sink drains', async () => { + const frames: Buffer[] = [] + const drainWaiters = new Set<() => void>() + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: (cb) => drainWaiters.add(cb) } + ) + try { + let firstSettled = false + const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => { + firstSettled = true + }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + + // First frame written, but its send has not settled and the second + // frame is not admitted while the sink stays saturated. + expect(frames).toHaveLength(1) + expect(firstSettled).toBe(false) + + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + await first + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(2) + } finally { + bulkDispatcher.dispose() + } + }) + + it('interactive notify() frames are not gated behind a stalled bulk lane', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: () => {} } + ) + try { + void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(1) + + bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' }) + expect(frames).toHaveLength(2) + const msg = JSON.parse( + decodeFirstFrame(frames[1]).payload.toString() + ) as JsonRpcNotification + expect(msg.method).toBe('pty.data') + } finally { + bulkDispatcher.dispose() + } + }) + + it('releases a parked bulk send when the dispatcher is disposed', async () => { + const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} }) + const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + await vi.advanceTimersByTimeAsync(0) + bulkDispatcher.dispose() + await expect(pending).resolves.toBeUndefined() + }) + + it('targets only the requested client and resolves for missing clients', async () => { + const primaryFrames: Buffer[] = [] + const secondaryFrames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + primaryFrames.push(Buffer.from(data)) + return true + }) + try { + const secondaryId = bulkDispatcher.attachClient((data) => { + secondaryFrames.push(Buffer.from(data)) + return true + }) + + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId }) + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + + await expect( + bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 }) + ).resolves.toBeUndefined() + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + } finally { + bulkDispatcher.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 41809a4d749..427d90e6ac0 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -27,10 +27,28 @@ export type MethodHandler = ( export type NotificationHandler = (params: Record, context: RequestContext) => void +/** Sink write. Returning literal `false` signals saturation (Node stream + * semantics); `void`/`true` mean the frame was accepted. */ +export type RelayClientWrite = (data: Buffer) => boolean | void + +export type RelayClientSinkOptions = { + /** One-shot: invoke `cb` once when the sink can accept more data again + * (stream 'drain'), or when the sink is permanently dead (error/close) so + * bulk senders waiting on it never hang. */ + waitWriteDrain?: (cb: () => void) => void +} + type RelayClient = { id: number decoder: FrameDecoder - write: (data: Buffer) => void + write: RelayClientWrite + waitWriteDrain?: (cb: () => void) => void + /** Pending resolvers for bulk sends stalled on sink saturation. Flushed on + * drain, write failure, detach, setWrite, and dispose so no pump hangs. */ + drainWaiters: Set<() => void> + /** Serializes bulk-lane sends per client so at most one bulk frame is + * admitted past the sink's high-water mark at a time. */ + bulkChain: Promise nextOutgoingSeq: number highestReceivedSeq: number generation: number @@ -58,8 +76,8 @@ export class RelayDispatcher { private nextClientId = 1 private nextRequestId = 1 - constructor(write: (data: Buffer) => void) { - this.primaryClient = this.createClient(write) + constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) { + this.primaryClient = this.createClient(write, sinkOptions) this.clients.set(this.primaryClient.id, this.primaryClient) this.startKeepalive() } @@ -75,10 +93,14 @@ export class RelayDispatcher { // never acks the new client's frames until the new client's seq catches // up - causing the client's unacked-timeout checker to accumulate stale // timestamps that could eventually fire a false connection-dead signal. - setWrite(write: (data: Buffer) => void): void { + setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.write = write + this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain this.primaryClient.closed = false + // Why: the saturated sink the waiters were parked on no longer exists; + // wake stalled bulk senders so they re-evaluate against the new sink. + this.flushDrainWaiters(this.primaryClient) this.resetClient(this.primaryClient) } @@ -89,14 +111,15 @@ export class RelayDispatcher { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.generation++ this.primaryClient.closed = true + this.flushDrainWaiters(this.primaryClient) this.notifyClientDetached(this.primaryClient.id) } // Why: synced remote workspaces can have more than one Orca client attached // to the same relay. Frame sequence numbers and JSON-RPC request ids are per // SSH channel, so each socket client needs independent protocol state. - attachClient(write: (data: Buffer) => void): number { - const client = this.createClient(write) + attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number { + const client = this.createClient(write, sinkOptions) this.clients.set(client.id, client) return client.id } @@ -109,6 +132,7 @@ export class RelayDispatcher { this.requestAborts.abortClient(clientId) client.generation++ client.closed = true + this.flushDrainWaiters(client) this.clients.delete(clientId) this.notifyClientDetached(clientId) } @@ -165,6 +189,90 @@ export class RelayDispatcher { } } + /** + * Bulk-lane notification. Sends are serialized per client and the returned + * promise resolves only after the sink accepted the frame without reporting + * saturation (or the client went away). Bulk producers (file streams) await + * this between frames so interactive frames (pty.data echo) never queue + * behind an unbounded backlog on the shared SSH channel. + * + * With `clientId`, the frame goes only to that client — stream chunks have + * exactly one consumer, and broadcasting them would let one slow secondary + * client stall everyone. A missing/closed target resolves immediately; the + * caller's staleness check owns aborting the stream. + */ + notifyBulk( + method: string, + params?: Record, + opts?: { clientId?: number } + ): Promise { + if (this.disposed) { + return Promise.resolve() + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + const targets = + opts?.clientId !== undefined + ? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined) + : Array.from(this.clients.values()) + const waits: Promise[] = [] + for (const client of targets) { + if (client.closed) { + continue + } + // Why: the frame is encoded inside the chain step, not at call time — + // sequence numbers must be assigned in actual write order. + const step = client.bulkChain.then(() => { + if (this.disposed || client.closed) { + return + } + const accepted = this.sendFrame(client, msg) + if (accepted === false) { + return this.waitForClientDrain(client) + } + return undefined + }) + client.bulkChain = step.catch(() => {}) + waits.push(step) + } + if (waits.length === 0) { + return Promise.resolve() + } + return Promise.all(waits).then(() => {}) + } + + private waitForClientDrain(client: RelayClient): Promise { + if (this.disposed || client.closed || !client.waitWriteDrain) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + client.drainWaiters.delete(finish) + resolve() + } + client.drainWaiters.add(finish) + try { + client.waitWriteDrain!(finish) + } catch { + finish() + } + }) + } + + private flushDrainWaiters(client: RelayClient): void { + for (const waiter of Array.from(client.drainWaiters)) { + waiter() + } + } + requestPrimary( method: string, params?: Record, @@ -236,14 +344,20 @@ export class RelayDispatcher { // Why: dispose means this relay instance cannot send responses anymore; // abort in-flight request work so stale SSH-side scans/watchers release. this.requestAborts.abortAll() + for (const client of this.clients.values()) { + this.flushDrainWaiters(client) + } } - private createClient(write: (data: Buffer) => void): RelayClient { + private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient { const id = this.nextClientId++ const client: RelayClient = { id, decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)), write, + waitWriteDrain: sinkOptions?.waitWriteDrain, + drainWaiters: new Set(), + bulkChain: Promise.resolve(), nextOutgoingSeq: 1, highestReceivedSeq: 0, generation: 0, @@ -387,13 +501,13 @@ export class RelayDispatcher { private sendFrame( client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification - ): void { + ): boolean | void { if (this.disposed || client.closed) { return } const seq = client.nextOutgoingSeq++ const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) - this.writeFrame(client, frame) + return this.writeFrame(client, frame) } private startKeepalive(): void { @@ -416,12 +530,13 @@ export class RelayDispatcher { this.keepaliveTimer.unref() } - private writeFrame(client: RelayClient, frame: Buffer): void { + private writeFrame(client: RelayClient, frame: Buffer): boolean | void { try { - client.write(frame) + return client.write(frame) } catch (err) { client.closed = true client.generation++ + this.flushDrainWaiters(client) if (client !== this.primaryClient) { this.clients.delete(client.id) this.notifyClientDetached(client.id) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 45a0685b8ac..003172c08fd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,7 +1,7 @@ import { open, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' +import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' import { BINARY_PROBE_BYTES, @@ -61,11 +61,21 @@ type StreamChunkReader = { ): Promise<{ bytesRead: number }> } +export type StreamPumpOptions = { + /** Client that requested the stream. Chunks go only to it — broadcasting + * bulk frames would let one slow secondary client stall the requester. */ + clientId?: number + /** True when the client declared `flowControl: 'ack'` — it sends + * fs.streamAck per processed chunk and the pump caps unacked chunks. */ + paceWithAcks: boolean +} + export async function readRelayFileStreamMetadata( filePath: string, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata( // Why: pumpChunks owns its own try/finally for handle release; the outer // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. + const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context) + void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) }) return { @@ -126,7 +137,8 @@ async function pumpChunks( totalSize: number, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions: StreamPumpOptions ): Promise { const entry = registry.get(streamId) if (!entry) { @@ -150,6 +162,27 @@ async function pumpChunks( endReason = 'aborted' break } + // Why: credit window — bulk chunks share one ordered SSH channel with + // interactive pty.data frames. Waiting for client acks bounds how many + // stream bytes a keystroke echo can queue behind, and yields the relay + // event loop so incoming keystrokes are handled between chunks. + if (pumpOptions.paceWithAcks) { + while ( + seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS && + !context.isStale() && + !registry.isAborted(streamId) + ) { + await registry.waitForAck(streamId) + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (registry.isAborted(streamId)) { + endReason = 'aborted' + break + } + } const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) if (bytesRead !== want) { @@ -167,7 +200,14 @@ async function pumpChunks( break } const data = buffer.subarray(0, bytesRead).toString('base64') - dispatcher.notify('fs.streamChunk', { streamId, seq, data }) + // Why: the bulk lane waits out sink saturation, so a flood of chunk + // frames cannot pile up in the outbound pipe ahead of interactive + // pty.data frames written via plain notify(). + await dispatcher.notifyBulk( + 'fs.streamChunk', + { streamId, seq, data }, + pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined + ) offset += bytesRead seq += 1 } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index d71dbf8c076..c7624afce65 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -34,6 +34,9 @@ function createMockDispatcher() { notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), + notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { + notifications.push({ method, params }) + }), _notifications: notifications, callRequest( method: string, @@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => { expect(err).toBeNull() }) + it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { + const filePath = path.join(tmpDir, 'paced.png') + const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + // Window of 4 admits seqs 0..3; seq 4 must wait for an ack. + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(4) + expect(collectStream(dispatcher).end).toBeNull() + + // Ack one chunk → exactly one more is admitted. + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) + await waitFor(() => collectStream(dispatcher).chunks.length === 5) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(5) + + for (let seq = 1; seq < 6; seq += 1) { + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq }) + } + await waitFor(() => collectStream(dispatcher).end !== null) + + const { chunks, err } = collectStream(dispatcher) + expect(err).toBeNull() + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + + it('releases a pump parked on the ack window when the stream is cancelled', async () => { + const filePath = path.join(tmpDir, 'parked-cancel.png') + writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + + const { end, err } = collectStream(dispatcher) + expect(end).toBeNull() + expect(err).toBeNull() + }) + it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 0c4a263f13e..c6564c51fbe 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -86,7 +86,13 @@ export class FsHandler { constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher this.registerHandlers() - this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) + this.dispatcher.onClientDetached?.((clientId) => { + this.releaseClientWatches(clientId) + // Why: a detached client's fs.streamAck frames will never arrive; wake + // any pump parked on the ack window so it re-checks staleness and exits + // instead of stranding its open file handle. + this.streamRegistry.wakeAllAckWaiters() + }) } private registerHandlers(): void { @@ -113,6 +119,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context)) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) + this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } private async readDir(params: Record) { @@ -148,7 +155,13 @@ export class FsHandler { private async readFileStream(params: Record, context?: RequestContext) { const filePath = expandTilde(params.filePath as string) const ctx = context ?? { clientId: 0, isStale: () => false } - return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx) + return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, { + // Why: only target the requesting client when the dispatcher actually + // routed this request (context present) — direct-call tests and legacy + // paths keep broadcast semantics. + ...(context ? { clientId: context.clientId } : {}), + paceWithAcks: params.flowControl === 'ack' + }) } private async tempDir(): Promise { @@ -162,6 +175,14 @@ export class FsHandler { } } + private streamAck(params: Record): void { + const streamId = params.streamId as number | undefined + const seq = params.seq as number | undefined + if (typeof streamId === 'number' && typeof seq === 'number') { + this.streamRegistry.recordAck(streamId, seq) + } + } + private async writeFile(params: Record) { const filePath = expandTilde(params.filePath as string) const content = params.content as string diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts new file mode 100644 index 00000000000..8cca2787891 --- /dev/null +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -0,0 +1,295 @@ +/** + * Regression: SSH typing latency under bulk file-stream load. + * + * The relay and the client share ONE ordered SSH channel. If the relay + * enqueues an entire file's fs.streamChunk frames into the outbound pipe + * at once, an interactive pty.data echo emitted mid-stream queues behind + * megabytes of bulk data and typing feels seconds-slow. + * + * These tests model the SSH channel as a congestible in-memory pipe and + * assert deterministic byte bounds instead of wall-clock latency: + * - with a saturated sink, the relay stalls the pump on write backpressure + * so at most ~1 chunk frame sits ahead of a pty echo; + * - with a fast sink but an unresponsive client, the fs.streamAck credit + * window bounds the in-flight backlog; + * - legacy clients that never ack still receive the full stream. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' +import { randomBytes } from 'node:crypto' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { STREAM_CHUNK_SIZE } from './protocol' + +// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header. +const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 +// Node pipe/socket sinks report saturation via write() === false past the HWM. +const SINK_HIGH_WATER_MARK = 64 * 1024 + +async function waitUntil( + predicate: () => boolean, + what: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitUntil timed out: ${what}`) + } + await new Promise((r) => setImmediate(r)) + } +} + +/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */ +async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { + let last = read() + let stable = 0 + while (stable < stableTurns) { + await new Promise((r) => setImmediate(r)) + const current = read() + if (current === last) { + stable += 1 + } else { + stable = 0 + last = current + } + } +} + +type Harness = { + mux: SshChannelMultiplexer + dispatcher: RelayDispatcher + fsHandler: FsHandler + queuedBytes: () => number + /** Deliver every queued relay→client buffer to the client mux. */ + deliverAll: () => void + /** Start delivering continuously on each macrotask turn. */ + startAutoDeliver: () => void + dispose: () => void +} + +function createHarness(opts: { congested: boolean }): Harness { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → relay: keystrokes and acks flow on the opposite direction of + // the duplex channel; they are not blocked by relay→client congestion. + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + + const outQueue: Buffer[] = [] + let queuedBytes = 0 + const drainWaiters = new Set<() => void>() + const fireDrainIfIdle = (): void => { + if (queuedBytes > 0) { + return + } + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + } + + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + outQueue.push(data) + queuedBytes += data.length + if (!opts.congested) { + return true + } + return queuedBytes < SINK_HIGH_WATER_MARK + }, + { + waitWriteDrain: (cb: () => void) => { + drainWaiters.add(cb) + fireDrainIfIdle() + } + } + ) + relayFeed = (data: Buffer) => dispatcher.feed(data) + + const deliverAll = (): void => { + while (outQueue.length > 0) { + const buf = outQueue.shift()! + queuedBytes -= buf.length + for (const cb of clientDataCallbacks) { + cb(buf) + } + } + fireDrainIfIdle() + } + + let autoDeliverTimer: ReturnType | null = null + const startAutoDeliver = (): void => { + if (autoDeliverTimer) { + return + } + autoDeliverTimer = setInterval(deliverAll, 1) + } + + const context = new RelayContext() + const fsHandler = new FsHandler(dispatcher, context) + const mux = new SshChannelMultiplexer(clientTransport) + + return { + mux, + dispatcher, + fsHandler, + queuedBytes: () => queuedBytes, + deliverAll, + startAutoDeliver, + dispose: () => { + if (autoDeliverTimer) { + clearInterval(autoDeliverTimer) + } + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + } + } +} + +describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => { + const harness = createHarness({ congested: true }) + try { + const filePath = path.join(tmpDir, 'big.png') + const original = randomBytes(3 * 1024 * 1024) // 12 chunks + writeFileSync(filePath, original) + + // Relay-side fake PTY: echoes input back immediately, mirroring + // PtyHandler's interactive fast path (dispatcher.notify on echo). + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + const readPromise = readFileViaStream(harness.mux, filePath) + // Deliver until the client has the stream metadata, then congest fully. + await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued') + harness.deliverAll() + // Let the pump run to whatever bound it enforces (pre-fix: whole file). + await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') + await waitUntilSettled(() => harness.queuedBytes()) + + // Type one key while the stream is congested. + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // The echo must not sit behind an unbounded chunk backlog: at most one + // in-flight bulk frame (the write that saturated the sink) plus slack. + expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) + + // Un-congest: the stream must still complete with intact content. + harness.startAutoDeliver() + const result = await readPromise + expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) + + it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'big.png') + writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks + + const receivedSeqs: number[] = [] + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + receivedSeqs.push(params.seq as number) + }) + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Raw ack-capable request without sending any acks: models a client + // whose main thread is too busy to process chunks. + const metadata = (await harness.mux.request('fs.readFileStream', { + filePath, + flowControl: 'ack' + })) as { streamId: number } + + await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') + await waitUntilSettled(() => receivedSeqs.length) + + // Without acks the relay must stop at the credit window, not flood + // the remaining chunks. + expect(receivedSeqs.length).toBeLessThanOrEqual(5) + expect(streamEnded).toBe(false) + + // Acking releases the window and the stream completes. + const totalChunks = 12 + for (let seq = 0; seq < totalChunks; seq += 1) { + harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) + } + await waitUntil(() => streamEnded, 'stream completed after acks') + expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i)) + } finally { + harness.dispose() + } + }, 30_000) + + it('still delivers the full stream to legacy clients that never ack', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'legacy.png') + const original = randomBytes(1024 * 1024 + 12345) + writeFileSync(filePath, original) + + const chunks = new Map() + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64')) + }) + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Legacy request shape: no flowControl param, and no acks ever sent. + await harness.mux.request('fs.readFileStream', { filePath }) + await waitUntil(() => streamEnded, 'legacy stream completed') + + const reassembled = Buffer.concat( + Array.from(chunks.entries()) + .sort(([a], [b]) => a - b) + .map(([, buf]) => buf) + ) + expect(reassembled.equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) +}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index e0bf2a5ed6f..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -1,9 +1,14 @@ import type { FileHandle } from 'node:fs/promises' -import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol' +import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol' type StreamEntry = { handle: FileHandle aborted: boolean + /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ + ackedThroughSeq: number + /** Pumps parked on the ack credit window. Woken by acks, abort, release, + * and a periodic stall recheck so a vanished client cannot strand a pump. */ + ackWaiters: Set<() => void> } export class TooManyStreamsError extends Error { @@ -22,7 +27,12 @@ export class RelayStreamRegistry { throw new TooManyStreamsError() } const streamId = this.nextId++ - this.streams.set(streamId, { handle, aborted: false }) + this.streams.set(streamId, { + handle, + aborted: false, + ackedThroughSeq: -1, + ackWaiters: new Set() + }) return streamId } @@ -30,6 +40,7 @@ export class RelayStreamRegistry { const entry = this.streams.get(streamId) if (entry) { entry.aborted = true + this.wakeAckWaiters(entry) } } @@ -41,11 +52,65 @@ export class RelayStreamRegistry { return this.streams.get(streamId) } + recordAck(streamId: number, seq: number): void { + const entry = this.streams.get(streamId) + if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) { + return + } + if (seq > entry.ackedThroughSeq) { + entry.ackedThroughSeq = seq + } + this.wakeAckWaiters(entry) + } + + ackedThroughSeq(streamId: number): number { + return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER + } + + /** Resolves on the next ack/abort/release for this stream, or after the + * stall-recheck interval so callers can re-evaluate staleness. */ + waitForAck(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry || entry.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS) + timer.unref?.() + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + entry.ackWaiters.delete(finish) + resolve() + } + entry.ackWaiters.add(finish) + }) + } + + /** Wake every parked pump (all streams) so it re-checks staleness — used + * when a client detaches and its acks will never arrive. */ + wakeAllAckWaiters(): void { + for (const entry of this.streams.values()) { + this.wakeAckWaiters(entry) + } + } + + private wakeAckWaiters(entry: StreamEntry): void { + for (const waiter of Array.from(entry.ackWaiters)) { + waiter() + } + } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { return } + this.wakeAckWaiters(entry) this.streams.delete(streamId) try { await entry.handle.close() diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 66818efaf61..267f6ef480f 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000 export const STREAM_CHUNK_SIZE = 256 * 1024 export const MAX_CONCURRENT_STREAMS = 16 +/** Max unacked fs.streamChunk frames in flight per stream when the client + * requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive + * pty.data frame can queue behind on the shared SSH channel (~1MB raw) while + * keeping the pipe full across one ack round-trip on fast links. */ +export const STREAM_ACK_WINDOW_CHUNKS = 4 + +/** Safety-valve poll interval for a pump stalled on acks: re-checks stream + * abort/staleness so a client that vanished mid-stream cannot park the pump + * (and its open file handle) forever. */ +export const STREAM_ACK_STALL_RECHECK_MS = 1_000 + export const RelayErrorCode = { TooManyStreams: -33006, StreamProtocolError: -33007 @@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer { } export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() sits on the hot receive path. Rebuilding one contiguous + // buffer per feed (Buffer.concat) re-copies every already-buffered byte for + // each incoming chunk — O(n²) per large frame. A chunk list assembles each + // frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -119,23 +135,32 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + if (length > MAX_MESSAGE_SIZE) { // Why: Throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse the leftover // payload bytes as a new header, corrupting every future frame. // Instead we skip the entire oversized frame so the decoder stays // synchronized with the stream. - if (this.buffer.length < totalLength) { - // Haven't received the full oversized payload yet; wait for more data. - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -145,23 +170,20 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 } // Why: at the handshake → dispatcher transition, the next consumer must @@ -169,10 +191,74 @@ export class FrameDecoder { // frame. This returns and clears the decoder's internal residue so the // caller can hand it to the dispatcher (or stdout pipe) without loss. drain(): Buffer { - const out = this.buffer - this.buffer = Buffer.alloc(0) + const out = + this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength) + this.reset() return out } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count + } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { diff --git a/src/relay/relay.ts b/src/relay/relay.ts index c930a244b65..99225227903 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -373,15 +373,44 @@ async function main(): Promise { // write to a dead pipe, silently failing or throwing EPIPE. When a // socket client reconnects, setWrite swaps the callback to the socket. let stdoutAlive = true - const dispatcher = new RelayDispatcher((data) => { - if (stdoutAlive) { + // Why: one-shot waiters parked by the dispatcher's bulk lane when stdout + // reports saturation (write() === false). Flushed on 'drain' and on every + // stdout-death path so a stalled file-stream pump never outlives the pipe + // it was waiting on. + const stdoutDrainWaiters = new Set<() => void>() + const flushStdoutDrainWaiters = (): void => { + for (const cb of Array.from(stdoutDrainWaiters)) { + stdoutDrainWaiters.delete(cb) + cb() + } + } + process.stdout.on('drain', flushStdoutDrainWaiters) + const dispatcher = new RelayDispatcher( + (data) => { + if (!stdoutAlive) { + return + } try { - process.stdout.write(data) + // Why: surface Node's backpressure signal to the dispatcher so bulk + // frames (fs.streamChunk) wait for drain instead of queueing megabytes + // ahead of interactive pty.data frames on the SSH channel. + return process.stdout.write(data) } catch { stdoutAlive = false + flushStdoutDrainWaiters() + return undefined + } + }, + { + waitWriteDrain: (cb) => { + if (!stdoutAlive) { + cb() + return + } + stdoutDrainWaiters.add(cb) } } - }) + ) const context = new RelayContext() @@ -680,11 +709,35 @@ async function main(): Promise { ) cancelGrace('socket client accepted') - const clientId = dispatcher.attachClient((data) => { - if (!sock.destroyed) { - sock.write(data) + // Why: same backpressure surface as the stdout sink — bulk frames wait + // for the socket to drain so they cannot bury interactive PTY frames. + const sockDrainWaiters = new Set<() => void>() + const flushSockDrainWaiters = (): void => { + for (const cb of Array.from(sockDrainWaiters)) { + sockDrainWaiters.delete(cb) + cb() } - }) + } + sock.on('drain', flushSockDrainWaiters) + sock.on('close', flushSockDrainWaiters) + sock.on('error', flushSockDrainWaiters) + const clientId = dispatcher.attachClient( + (data) => { + if (!sock.destroyed) { + return sock.write(data) + } + return undefined + }, + { + waitWriteDrain: (cb) => { + if (sock.destroyed) { + cb() + return + } + sockDrainWaiters.add(cb) + } + } + ) socketClients.set(sock, clientId) // Why: bytes that arrived in the same TCP send as the handshake frame @@ -889,6 +942,7 @@ async function main(): Promise { // before the grace period starts. process.stdout.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() }) @@ -934,6 +988,7 @@ async function main(): Promise { // dead so the primary client write callback becomes a no-op while // socket clients, if any, keep their own live transports. stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin ended') @@ -942,6 +997,7 @@ async function main(): Promise { process.stdin.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin error') diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index ddcbee65586..c8e3beab6e5 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -207,6 +207,104 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps remote typing responsive while relay file streams and git churn are active', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + // Large remote binaries: each read streams ~8MB of fs.streamChunk frames + // over the same SSH channel that carries the pty echo. + const loadFiles = [ + `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-a.png`, + `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/stream-load-b.png` + ] + await execInTerminal( + orcaPage, + ptyId, + `dd if=/dev/urandom of=${shellQuote(loadFiles[0])} bs=1M count=8 status=none && ` + + `dd if=/dev/urandom of=${shellQuote(loadFiles[1])} bs=1M count=8 status=none && ` + + `echo LOAD_FILES_READY_${runId}` + ) + await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) + + await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) + + // Background relay pressure: continuous large file reads plus git status + // refreshes, mirroring file preview + source-control churn while typing. + await orcaPage.evaluate( + ({ targetId, files, repoPath }) => { + const state = { stopped: false, reads: 0, errors: [] as string[] } + ;(window as unknown as { __sshRelayLoad: typeof state }).__sshRelayLoad = state + const loop = async (run: () => Promise): Promise => { + while (!state.stopped) { + try { + await run() + state.reads += 1 + } catch (err) { + state.errors.push(String(err)) + await new Promise((r) => setTimeout(r, 100)) + } + } + } + for (const filePath of files) { + void loop(() => window.api.fs.readFile({ filePath, connectionId: targetId })) + } + void loop(() => window.api.git.status({ worktreePath: repoPath, connectionId: targetId })) + }, + { + targetId: remote.targetId, + files: loadFiles, + repoPath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH + } + ) + // Let the bulk load ramp before measuring. + await orcaPage.waitForTimeout(1_000) + + const measurement = await measureRemoteTyping(orcaPage, ptyId, runId) + const load = await orcaPage.evaluate(() => { + const state = ( + window as unknown as { + __sshRelayLoad: { stopped: boolean; reads: number; errors: string[] } + } + ).__sshRelayLoad + state.stopped = true + return { reads: state.reads, errors: state.errors.slice(0, 3) } + }) + + const summary = + `median=${measurement.medianLatencyMs.toFixed(1)}ms ` + + `worst=${measurement.worstLatencyMs.toFixed(1)}ms ` + + `bulkReads=${load.reads} ` + + `samples=${measurement.latencies.map((value) => value.toFixed(1)).join(',')}` + console.log(`[docker-ssh-relay-perf:busy] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-typing-busy', + description: summary + }) + + // The load must actually have been streaming and error-free, otherwise + // the latency numbers prove nothing. + expect(load.errors).toEqual([]) + expect(load.reads).toBeGreaterThan(0) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + await stopRemoteLoad(orcaPage, ptyId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps an SSH workspace terminal usable after disconnect and reconnect', async ({ orcaPage }, testInfo) => { From f311307560dadd68ddd8c4574c30353c364c2e21 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:12:27 -0700 Subject: [PATCH 16/46] Add max-lines ratchet CI gate to block new line-limit bypasses (#7608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oxlint already fails any file over max-lines that is not suppressed, so the only way to grow past the budget is to add an eslint/oxlint-disable max-lines comment or a per-file max-lines bump in mobile/.oxlintrc.json. This adds a CI gate that freezes the current set of suppressions (config/max-lines-baseline.txt, 355 grandfathered entries) and fails the build when a NEW one appears — with a loud, actionable message pointing at 'split the file'. Existing oversized files are untouched; the baseline may only shrink (pnpm check:max-lines-ratchet --prune). Wired into the root lint script and as a dedicated pr.yml step. Unit-tested (15 cases) and verified against all three failure paths + clean-tree pass. Co-authored-by: Orca --- .github/workflows/pr.yml | 8 + AGENTS.md | 4 +- config/max-lines-baseline.txt | 359 ++++++++++++++++++ config/scripts/check-max-lines-ratchet.mjs | 254 +++++++++++++ .../scripts/check-max-lines-ratchet.test.mjs | 115 ++++++ package.json | 3 +- 6 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 config/max-lines-baseline.txt create mode 100644 config/scripts/check-max-lines-ratchet.mjs create mode 100644 config/scripts/check-max-lines-ratchet.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d9f19dcc1b8..2f91c946bc7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,6 +69,14 @@ jobs: - name: Check reliability gate manifest run: pnpm run check:reliability-gates + # Why: oxlint fails any file over max-lines that is NOT suppressed, so this + # ratchet forbids ADDING a new suppression (inline disable or mobile max + # bump). Existing oversized files are grandfathered in + # config/max-lines-baseline.txt, which may only shrink — new bypasses fail + # here with a clear message instead of silently growing the debt. + - name: Enforce max-lines ratchet (no new bypasses) + run: pnpm run check:max-lines-ratchet + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/AGENTS.md b/AGENTS.md index 647c598a25e..b10035e9b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s ## Lint Rules: Do Not Disable Max Lines -Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. +Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. + +This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line. ## File and Module Naming diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt new file mode 100644 index 00000000000..543ff2e6a60 --- /dev/null +++ b/config/max-lines-baseline.txt @@ -0,0 +1,359 @@ +# Files/globs currently allowed to exceed the oxlint `max-lines` budget. +# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — +# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). +# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) +inline mobile/src/constants/marine-creatures.ts +inline src/cli/handlers/automations.ts +inline src/cli/handlers/orchestration.ts +inline src/cli/help.ts +inline src/cli/index.test.ts +inline src/main/agent-hooks/server.test.ts +inline src/main/agent-hooks/server.ts +inline src/main/amp/hook-service.ts +inline src/main/antigravity/hook-service.ts +inline src/main/attribution/terminal-attribution.ts +inline src/main/automations/external-manager.ts +inline src/main/automations/hermes-cron-output.ts +inline src/main/browser/agent-browser-bridge.test.ts +inline src/main/browser/agent-browser-bridge.ts +inline src/main/browser/browser-cookie-import.ts +inline src/main/browser/browser-guest-ui.ts +inline src/main/browser/browser-manager-grab.test.ts +inline src/main/browser/browser-manager.test.ts +inline src/main/browser/browser-manager.ts +inline src/main/browser/browser-screencast-stream.ts +inline src/main/browser/browser-session-registry.ts +inline src/main/browser/cdp-bridge.ts +inline src/main/browser/cdp-ws-proxy.ts +inline src/main/browser/grab-guest-script.ts +inline src/main/browser/snapshot-engine.ts +inline src/main/claude-accounts/runtime-auth-service.test.ts +inline src/main/claude-accounts/runtime-auth-service.ts +inline src/main/claude-accounts/service.test.ts +inline src/main/claude-accounts/service.ts +inline src/main/claude-usage/scanner.ts +inline src/main/claude-usage/store.ts +inline src/main/cli/cli-installer.test.ts +inline src/main/cli/cli-installer.ts +inline src/main/cli/wsl-cli-installer.ts +inline src/main/codex-accounts/runtime-home-service.test.ts +inline src/main/codex-accounts/runtime-home-service.ts +inline src/main/codex-accounts/service.test.ts +inline src/main/codex-accounts/service.ts +inline src/main/codex-usage/scanner.ts +inline src/main/codex-usage/store.test.ts +inline src/main/codex-usage/store.ts +inline src/main/codex/config-toml-trust.test.ts +inline src/main/codex/config-toml-trust.ts +inline src/main/codex/hook-service.test.ts +inline src/main/codex/hook-service.ts +inline src/main/copilot/hook-service.ts +inline src/main/daemon/client.ts +inline src/main/daemon/daemon-health.ts +inline src/main/daemon/daemon-init.test.ts +inline src/main/daemon/daemon-init.ts +inline src/main/daemon/daemon-pty-adapter.test.ts +inline src/main/daemon/daemon-pty-adapter.ts +inline src/main/daemon/daemon-server.ts +inline src/main/daemon/pty-subprocess.test.ts +inline src/main/daemon/pty-subprocess.ts +inline src/main/daemon/session.ts +inline src/main/daemon/shell-ready.ts +inline src/main/git/remove-worktree.test.ts +inline src/main/git/repo.ts +inline src/main/git/runner.ts +inline src/main/git/status.test.ts +inline src/main/git/status.ts +inline src/main/git/worktree.test.ts +inline src/main/git/worktree.ts +inline src/main/github/client.test.ts +inline src/main/github/client.ts +inline src/main/github/issues.ts +inline src/main/github/pr-refresh-coordinator.test.ts +inline src/main/github/pr-refresh-coordinator.ts +inline src/main/github/project-view.ts +inline src/main/github/project-view/mutations.ts +inline src/main/github/work-item-details.ts +inline src/main/gitlab/client-mr.test.ts +inline src/main/gitlab/client.ts +inline src/main/gitlab/issues.ts +inline src/main/gitlab/work-item-details.ts +inline src/main/hermes/hook-service.ts +inline src/main/hooks.test.ts +inline src/main/hooks.ts +inline src/main/index.ts +inline src/main/ipc/browser.ts +inline src/main/ipc/crash-reporting.ts +inline src/main/ipc/filesystem-auth.ts +inline src/main/ipc/filesystem-mutations.ts +inline src/main/ipc/filesystem-watcher.ts +inline src/main/ipc/filesystem.test.ts +inline src/main/ipc/filesystem.ts +inline src/main/ipc/github.test.ts +inline src/main/ipc/github.ts +inline src/main/ipc/gitlab.ts +inline src/main/ipc/linear.ts +inline src/main/ipc/notifications.test.ts +inline src/main/ipc/notifications.ts +inline src/main/ipc/pet.ts +inline src/main/ipc/preflight.test.ts +inline src/main/ipc/pty.test.ts +inline src/main/ipc/pty.ts +inline src/main/ipc/remote-workspace.ts +inline src/main/ipc/repos-remote.test.ts +inline src/main/ipc/repos.ts +inline src/main/ipc/runtime-environments.test.ts +inline src/main/ipc/ssh.test.ts +inline src/main/ipc/ssh.ts +inline src/main/ipc/worktree-remote.ts +inline src/main/ipc/worktrees.test.ts +inline src/main/ipc/worktrees.ts +inline src/main/jira/client.ts +inline src/main/jira/issues.ts +inline src/main/keybindings/keybinding-file.ts +inline src/main/linear/client.ts +inline src/main/linear/issues.ts +inline src/main/linear/projects.ts +inline src/main/memory/collector.ts +inline src/main/opencode-usage/scanner.ts +inline src/main/opencode-usage/store.ts +inline src/main/opencode/hook-service.ts +inline src/main/persistence.test.ts +inline src/main/persistence.ts +inline src/main/ports/advertised-url-watcher.ts +inline src/main/ports/local-workspace-port-scanner.ts +inline src/main/project-groups/nested-repo-discovery.ts +inline src/main/providers/local-pty-provider.test.ts +inline src/main/providers/local-pty-provider.ts +inline src/main/providers/local-pty-shell-ready.test.ts +inline src/main/providers/local-pty-shell-ready.ts +inline src/main/providers/ssh-git-provider.test.ts +inline src/main/providers/ssh-git-provider.ts +inline src/main/rate-limits/claude-fetcher.test.ts +inline src/main/rate-limits/claude-fetcher.ts +inline src/main/rate-limits/claude-pty.ts +inline src/main/rate-limits/codex-fetcher.ts +inline src/main/rate-limits/service.test.ts +inline src/main/rate-limits/service.ts +inline src/main/runtime/orca-runtime-browser.ts +inline src/main/runtime/orca-runtime-files.test.ts +inline src/main/runtime/orca-runtime-files.ts +inline src/main/runtime/orca-runtime-git.ts +inline src/main/runtime/orca-runtime.test.ts +inline src/main/runtime/orca-runtime.ts +inline src/main/runtime/orchestration/coordinator.ts +inline src/main/runtime/orchestration/db.ts +inline src/main/runtime/rpc/methods/files.ts +inline src/main/runtime/rpc/methods/git.ts +inline src/main/runtime/rpc/methods/github.ts +inline src/main/runtime/rpc/methods/orchestration.test.ts +inline src/main/runtime/rpc/methods/orchestration.ts +inline src/main/runtime/rpc/methods/terminal.ts +inline src/main/runtime/rpc/terminal-multiplex.test.ts +inline src/main/runtime/runtime-rpc.test.ts +inline src/main/runtime/runtime-rpc.ts +inline src/main/source-control/hosted-review-creation.ts +inline src/main/speech/model-manager.ts +inline src/main/speech/stt-service.ts +inline src/main/ssh/ssh-channel-multiplexer.ts +inline src/main/ssh/ssh-connection.test.ts +inline src/main/ssh/ssh-connection.ts +inline src/main/ssh/ssh-relay-deploy.ts +inline src/main/ssh/ssh-relay-session.ts +inline src/main/star-nag/service.test.ts +inline src/main/text-generation/commit-message-text-generation.test.ts +inline src/main/text-generation/commit-message-text-generation.ts +inline src/main/updater.test.ts +inline src/main/updater.ts +inline src/main/window/attach-main-window-services.ts +inline src/main/window/createMainWindow.test.ts +inline src/main/window/createMainWindow.ts +inline src/main/workspace-space-analysis.ts +inline src/preload/api-types.ts +inline src/preload/index.ts +inline src/relay/agent-hook-server.ts +inline src/relay/dispatcher.ts +inline src/relay/external-automations-handler.ts +inline src/relay/fs-handler.ts +inline src/relay/git-handler.test.ts +inline src/relay/git-handler.ts +inline src/relay/pty-handler.test.ts +inline src/relay/pty-handler.ts +inline src/relay/relay.ts +inline src/relay/workspace-space-scan.ts +inline src/renderer/src/App.tsx +inline src/renderer/src/components/GitHubItemDialog.tsx +inline src/renderer/src/components/GitLabItemDialog.tsx +inline src/renderer/src/components/JiraIssueWorkspace.tsx +inline src/renderer/src/components/LinearIssueWorkspace.tsx +inline src/renderer/src/components/LinearItemDrawer.tsx +inline src/renderer/src/components/NewWorkspaceComposerCard.tsx +inline src/renderer/src/components/PullRequestPage.tsx +inline src/renderer/src/components/TaskPage.tsx +inline src/renderer/src/components/Terminal.tsx +inline src/renderer/src/components/UpdateCard.tsx +inline src/renderer/src/components/WorktreeJumpPalette.tsx +inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts +inline src/renderer/src/components/activity/ActivityPrototypePage.tsx +inline src/renderer/src/components/automations/AutomationsPage.tsx +inline src/renderer/src/components/browser-pane/BrowserPane.tsx +inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +inline src/renderer/src/components/editor/CombinedDiffViewer.tsx +inline src/renderer/src/components/editor/EditorContent.tsx +inline src/renderer/src/components/editor/IpynbViewer.tsx +inline src/renderer/src/components/editor/MarkdownPreview.tsx +inline src/renderer/src/components/editor/MonacoEditor.tsx +inline src/renderer/src/components/editor/editor-autosave-controller.ts +inline src/renderer/src/components/editor/ipynb-parse.ts +inline src/renderer/src/components/editor/useEditorPanelContentState.ts +inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +inline src/renderer/src/components/github-project/ProjectCell.tsx +inline src/renderer/src/components/github-project/ProjectPicker.tsx +inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx +inline src/renderer/src/components/linear-project-view-surfaces.tsx +inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +inline src/renderer/src/components/onboarding/use-onboarding-flow.ts +inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.tsx +inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +inline src/renderer/src/components/right-sidebar/PortsPanel.tsx +inline src/renderer/src/components/right-sidebar/SourceControl.tsx +inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx +inline src/renderer/src/components/right-sidebar/index.tsx +inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +inline src/renderer/src/components/settings/AccountsPane.tsx +inline src/renderer/src/components/settings/AgentsPane.tsx +inline src/renderer/src/components/settings/RepositoryHooksSection.tsx +inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +inline src/renderer/src/components/settings/Settings.tsx +inline src/renderer/src/components/settings/SettingsFormControls.tsx +inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +inline src/renderer/src/components/sidebar/WorktreeCard.tsx +inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +inline src/renderer/src/components/sidebar/WorktreeList.tsx +inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.ts +inline src/renderer/src/components/stats/usage-overview-model.ts +inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +inline src/renderer/src/components/status-bar/StatusBar.tsx +inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +inline src/renderer/src/components/tab-bar/TabBar.tsx +inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +inline src/renderer/src/components/tab-group/useTabDragSplit.ts +inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +inline src/renderer/src/components/task-page-cache-selectors.ts +inline src/renderer/src/components/terminal-pane/TerminalPane.tsx +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts +inline src/renderer/src/components/terminal-pane/pty-connection.test.ts +inline src/renderer/src/components/terminal-pane/pty-connection.ts +inline src/renderer/src/components/terminal-pane/pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/pty-transport.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +inline src/renderer/src/hooks/useAutomationDispatchEvents.ts +inline src/renderer/src/hooks/useComposerState.ts +inline src/renderer/src/hooks/useEditorExternalWatch.ts +inline src/renderer/src/hooks/useIpcEvents.test.ts +inline src/renderer/src/hooks/useIpcEvents.ts +inline src/renderer/src/hooks/useIssueMetadata.ts +inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts +inline src/renderer/src/lib/active-agent-note-send.test.ts +inline src/renderer/src/lib/file-type-icons.ts +inline src/renderer/src/lib/pane-manager/pane-manager.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts +inline src/renderer/src/lib/terminal-links.ts +inline src/renderer/src/lib/worktree-activation.test.ts +inline src/renderer/src/lib/worktree-activation.ts +inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +inline src/renderer/src/runtime/runtime-file-client.test.ts +inline src/renderer/src/runtime/runtime-file-client.ts +inline src/renderer/src/runtime/runtime-git-client.ts +inline src/renderer/src/runtime/runtime-linear-client.ts +inline src/renderer/src/runtime/sync-runtime-graph.test.ts +inline src/renderer/src/runtime/sync-runtime-graph.ts +inline src/renderer/src/runtime/web-runtime-session.test.ts +inline src/renderer/src/runtime/web-runtime-session.ts +inline src/renderer/src/runtime/web-session-tabs-sync.test.ts +inline src/renderer/src/runtime/web-session-tabs-sync.ts +inline src/renderer/src/store/slices/agent-status.test.ts +inline src/renderer/src/store/slices/agent-status.ts +inline src/renderer/src/store/slices/browser.test.ts +inline src/renderer/src/store/slices/browser.ts +inline src/renderer/src/store/slices/diffComments.ts +inline src/renderer/src/store/slices/editor.test.ts +inline src/renderer/src/store/slices/editor.ts +inline src/renderer/src/store/slices/github.test.ts +inline src/renderer/src/store/slices/github.ts +inline src/renderer/src/store/slices/hosted-review.ts +inline src/renderer/src/store/slices/jira.ts +inline src/renderer/src/store/slices/linear.test.ts +inline src/renderer/src/store/slices/linear.ts +inline src/renderer/src/store/slices/repos.ts +inline src/renderer/src/store/slices/store-cascades.test.ts +inline src/renderer/src/store/slices/store-session-cascades.test.ts +inline src/renderer/src/store/slices/tabs.test.ts +inline src/renderer/src/store/slices/tabs.ts +inline src/renderer/src/store/slices/terminals.ts +inline src/renderer/src/store/slices/ui.test.ts +inline src/renderer/src/store/slices/ui.ts +inline src/renderer/src/store/slices/workspace-cleanup.ts +inline src/renderer/src/store/slices/worktrees.test.ts +inline src/renderer/src/store/slices/worktrees.ts +inline src/renderer/src/web/web-preload-api.test.ts +inline src/renderer/src/web/web-preload-api.ts +inline src/renderer/src/web/web-runtime-client.ts +inline src/shared/agent-hook-listener.test.ts +inline src/shared/agent-hook-listener.ts +inline src/shared/automation-schedules.ts +inline src/shared/commit-message-agent-spec.ts +inline src/shared/constants.ts +inline src/shared/github-project-types.ts +inline src/shared/keybindings.test.ts +inline src/shared/keybindings.ts +inline src/shared/marine-creatures.ts +inline src/shared/remote-runtime-client.ts +inline src/shared/runtime-types.ts +inline src/shared/source-control-ai.ts +inline src/shared/telemetry-events.ts +inline src/shared/text-search.ts +inline src/shared/types.ts +inline tests/e2e/helpers/terminal.ts +inline tests/e2e/terminal-panes.spec.ts +mobile-config app/h/*/files/*.tsx +mobile-config app/h/*/index.tsx +mobile-config app/h/*/session/*.tsx +mobile-config app/h/*/source-control/*.tsx +mobile-config app/h/*/tasks.tsx +mobile-config app/index.tsx +mobile-config app/pair-scan.tsx +mobile-config app/terminal-settings.tsx +mobile-config app/troubleshoot.tsx +mobile-config scripts/mock-server.ts +mobile-config scripts/repro-terminal-colors.ts +mobile-config scripts/repro-worktree-startup-stream.ts +mobile-config src/browser/MobileBrowserPane.tsx +mobile-config src/components/CustomKeyModal.tsx +mobile-config src/components/NewWorktreeModal.tsx +mobile-config src/components/mobile-rich-markdown-editor-html.ts +mobile-config src/terminal/terminal-accessory-keys.ts +mobile-config src/terminal/terminal-webview-html.ts +mobile-config src/transport/rpc-client.ts diff --git a/config/scripts/check-max-lines-ratchet.mjs b/config/scripts/check-max-lines-ratchet.mjs new file mode 100644 index 00000000000..d4e8b9fa072 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.mjs @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// Ratchet gate for the oxlint `max-lines` rule. +// +// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so +// the only way a file grows past the budget is by adding an `eslint/oxlint-disable +// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This +// check freezes the set of files currently allowed to do that (the baseline) and +// fails CI when a NEW bypass appears — the existing over-limit files are +// grandfathered; new ones must split instead. The baseline may only shrink. + +const BASELINE_PATH = 'config/max-lines-baseline.txt' +const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json' +// These two files legitimately contain the directive text as data (regex, fixtures), +// so scanning them would self-flag. The ratchet does not police itself. +const SELF_FILES = new Set([ + 'config/scripts/check-max-lines-ratchet.mjs', + 'config/scripts/check-max-lines-ratchet.test.mjs' +]) + +// Default max-lines budgets from .oxlintrc.json (counted lines). +export function defaultLimitForPath(p) { + if (/\.(test|spec)\.(ts|tsx)$/.test(p)) { + return 800 + } + if (p.endsWith('.tsx')) { + return 400 + } + if (p.endsWith('.mjs')) { + return 600 + } + return 300 +} + +// True if the source contains an eslint/oxlint disable directive listing `max-lines` +// (block or line, bare or compound, with or without a `-- Why:` reason). +export function hasMaxLinesDisable(sourceText) { + const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g + let m + while ((m = re.exec(sourceText)) !== null) { + let rules = m[1] + rules = rules.split('--')[0] // strip the reason + const close = rules.indexOf('*/') + if (close !== -1) { + rules = rules.slice(0, close) // strip block-comment tail + } + if (/\bmax-lines\b/.test(rules)) { + return true + } + } + return false +} + +// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the +// default for that glob (a lower `max` is stricter, not a bypass). +export function collectMobileBumps(configText) { + const cfg = JSON.parse(configText) + const bumps = [] + for (const override of cfg.overrides ?? []) { + const rule = override.rules?.['max-lines'] + if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') { + continue + } + for (const glob of override.files ?? []) { + if (rule[1].max > defaultLimitForPath(glob)) { + bumps.push(`mobile-config ${glob}`) + } + } + } + return bumps +} + +export function parseBaseline(text) { + return new Set( + text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + ) +} + +export function diffBaseline(current, baseline) { + const cur = new Set(current) + const base = baseline instanceof Set ? baseline : new Set(baseline) + const added = [...cur].filter((e) => !base.has(e)).sort() + const stale = [...base].filter((e) => !cur.has(e)).sort() + return { added, stale } +} + +// Collect every current suppression entry from the tracked tree. +export function collectCurrentSuppressions(root = process.cwd()) { + const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + }) + .split('\n') + .filter(Boolean) + .filter((f) => !SELF_FILES.has(f)) + + const entries = [] + for (const rel of tracked) { + let src + try { + src = fs.readFileSync(path.join(root, rel), 'utf8') + } catch { + continue + } + if (hasMaxLinesDisable(src)) { + entries.push(`inline ${rel}`) + } + } + + const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH) + if (fs.existsSync(mobileCfgPath)) { + entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8'))) + } + + return entries.sort() +} + +function printAddedFailure(added) { + for (const entry of added) { + console.error(`::error::New max-lines bypass not allowed: ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`) + console.error('') + for (const entry of added) { + const [kind, ...rest] = entry.split(' ') + const target = rest.join(' ') + const how = + kind === 'inline' + ? 'added an eslint/oxlint-disable max-lines comment' + : 'added a per-file max-lines bump in mobile/.oxlintrc.json' + console.error(` • ${target}\n ↳ ${how}`) + } + console.error('') + console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,') + console.error( + ' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.' + ) + console.error('') + console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.') + console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".') + console.error('') + console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable') + console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`) + console.error('') +} + +function printStaleFailure(stale) { + for (const entry of stale) { + console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`) + console.error( + ' The baseline may only shrink, so these must be removed to keep re-adding blocked:' + ) + console.error('') + for (const entry of stale) { + console.error(` • ${entry}`) + } + console.error('') + console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`) + console.error('') +} + +export function main(root = process.cwd()) { + const baselineFile = path.join(root, BASELINE_PATH) + if (!fs.existsSync(baselineFile)) { + console.error( + `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init` + ) + return 1 + } + const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + const current = collectCurrentSuppressions(root) + const { added, stale } = diffBaseline(current, baseline) + + if (added.length > 0) { + printAddedFailure(added) + if (stale.length > 0) { + console.error( + ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)` + ) + printStaleFailure(stale) + } + return 1 + } + if (stale.length > 0) { + printStaleFailure(stale) + return 1 + } + console.log( + `max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.` + ) + return 0 +} + +function writeBaseline(root, entries) { + const header = [ + '# Files/globs currently allowed to exceed the oxlint `max-lines` budget.', + '# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —', + '# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").', + '# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)', + '' + ].join('\n') + fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd() + const arg = process.argv[2] + if (arg === '--init') { + // One-time bootstrap: capture the current suppression set as the baseline. + const entries = collectCurrentSuppressions(root) + writeBaseline(root, entries) + console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`) + process.exit(0) + } + if (arg === '--prune') { + // Remove baseline entries whose suppression is gone (shrink only; never adds). + const current = new Set(collectCurrentSuppressions(root)) + const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8')) + const kept = [...baseline].filter((e) => current.has(e)).sort() + const newlyAdded = [...current].filter((e) => !baseline.has(e)) + writeBaseline(root, kept) + console.log( + `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).` + ) + if (newlyAdded.length > 0) { + console.error( + `::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.` + ) + process.exit(1) + } + process.exit(0) + } + process.exit(main(root)) +} diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs new file mode 100644 index 00000000000..f1eb4183454 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.test.mjs @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { + collectMobileBumps, + defaultLimitForPath, + diffBaseline, + hasMaxLinesDisable, + parseBaseline +} from './check-max-lines-ratchet.mjs' + +describe('hasMaxLinesDisable', () => { + it('detects a bare block disable', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true) + }) + + it('detects the oxlint spelling', () => { + expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true) + }) + + it('detects a disable with a -- Why reason', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true) + }) + + it('detects a multi-line block where the reason wraps', () => { + const src = + '/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n' + expect(hasMaxLinesDisable(src)).toBe(true) + }) + + it('detects max-lines inside a compound rule list', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe( + true + ) + expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true) + }) + + it('detects a line-scoped disable', () => { + expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true) + }) + + it('ignores a disable for an unrelated rule', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false) + }) + + it('does not treat "max-lines" appearing only in the reason text as a suppression', () => { + // max-lines is after the `--`, so it is prose, not a suppressed rule. + expect( + hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n') + ).toBe(false) + }) + + it('returns false for ordinary source', () => { + expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false) + }) +}) + +describe('defaultLimitForPath', () => { + it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => { + expect(defaultLimitForPath('a/b.test.ts')).toBe(800) + expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800) + expect(defaultLimitForPath('a/b.tsx')).toBe(400) + expect(defaultLimitForPath('a/b.mjs')).toBe(600) + expect(defaultLimitForPath('a/b.ts')).toBe(300) + }) +}) + +describe('collectMobileBumps', () => { + it('captures only overrides whose max exceeds the default for the glob', () => { + const cfg = JSON.stringify({ + overrides: [ + { files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400) + { + files: ['src/terminal/TerminalWebView.tsx'], + rules: { 'max-lines': ['error', { max: 379 }] } + }, // stricter (<400), skip + { files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300) + ] + }) + expect(collectMobileBumps(cfg)).toEqual([ + 'mobile-config app/h/*/tasks.tsx', + 'mobile-config scripts/mock-server.ts' + ]) + }) + + it('ignores overrides without a max-lines rule', () => { + const cfg = JSON.stringify({ + overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }] + }) + expect(collectMobileBumps(cfg)).toEqual([]) + }) +}) + +describe('parseBaseline', () => { + it('drops comments and blank lines', () => { + const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n') + expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx'])) + }) +}) + +describe('diffBaseline', () => { + it('reports added and stale entries', () => { + const { added, stale } = diffBaseline( + ['inline b.ts', 'inline c.ts'], + new Set(['inline a.ts', 'inline b.ts']) + ) + expect(added).toEqual(['inline c.ts']) // new bypass + expect(stale).toEqual(['inline a.ts']) // suppression removed + }) + + it('is clean when current matches baseline', () => { + const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts'])) + expect(added).toEqual([]) + expect(stale).toEqual([]) + }) +}) diff --git a/package.json b/package.json index b9fe4f6721f..a793e5c51af 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -19,6 +19,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", + "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", From 9a3bf1293cfc7d03bef243249e30e0264a6f3aae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:13:21 -0700 Subject: [PATCH 17/46] Mobile file tree follow-ups: reconnect no-blank + old-desktop files.list fallback (#7599) Co-authored-by: Orca --- .../src/files/MobileFileExplorerPanel.test.ts | 171 ++++++++++++++++++ mobile/src/files/MobileFileExplorerPanel.tsx | 54 +++++- mobile/src/files/file-list-fallback.test.ts | 65 +++++++ mobile/src/files/file-list-fallback.ts | 73 ++++++++ 4 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 mobile/src/files/file-list-fallback.test.ts create mode 100644 mobile/src/files/file-list-fallback.ts diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index b86e722f1dc..0d003e9f456 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -224,4 +224,175 @@ describe('MobileFileExplorerPanel', () => { expect(renderedText(renderer)).toContain('README.md') expect(renderedText(renderer)).not.toContain('Waiting for desktop...') }) + + it('refreshes the root in the background after reconnect without blanking the tree', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + let resolveReload: ((response: RpcResponse) => void) | undefined + const reconnectedClient: MockClient = { + sendRequest: vi.fn( + () => + new Promise((resolve) => { + resolveReload = resolve + }) + ) + } + mockTransport.client = reconnectedClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await act(async () => { + resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')])) + }) + expect(renderedText(renderer)).toContain('CHANGELOG.md') + }) + + it('keeps the cached tree when a post-reconnect root refresh fails', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + const failingClient: MockClient = { + sendRequest: vi.fn(async () => { + throw new Error('refresh failed') + }) + } + mockTransport.client = failingClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(failingClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('refresh failed') + }) + + it('falls back to the capped files.list against desktops without files.readDir', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [ + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ], + totalCount: 2, + truncated: false + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', { + worktree: 'id:worktree-a' + }) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await pressByLabel(renderer, 'Open folder src') + expect(renderedText(renderer)).toContain('app.ts') + // Every directory comes from the synthesized cache: one readDir attempt + // plus one files.list call total, no per-directory RPCs afterwards. + expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('surfaces the legacy cap note when the files.list fallback is truncated', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }], + totalCount: 6000, + truncated: true + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).toContain('Showing first 5000') + }) + + it('reports the files.list failure when the fallback itself fails', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: false, + error: { code: 'internal', message: 'legacy list failed' }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('legacy list failed') + expect(renderedText(renderer)).not.toContain('not available to mobile clients') + }) }) diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index b777c1379e5..7e67bf81b5f 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -28,6 +28,11 @@ import { resetDirectoryLoadRevisions, type DirectoryLoadRevisions } from './directory-load-revisions' +import { + directoryCacheFromFileList, + isMobileMethodUnavailableError, + type LegacyFilesListResult +} from './file-list-fallback' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' @@ -53,6 +58,7 @@ export function MobileFileExplorerPanel(props: { const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [legacyListTruncated, setLegacyListTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) const loadDirectory = useCallback( @@ -82,8 +88,14 @@ export function MobileFileExplorerPanel(props: { return } + const hadLoadedRoot = + rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 if (rootLoad) { - setLoading(true) + // Why: a reconnect refresh must not blank an already browsable tree — + // the full-screen spinner unmounts the list and resets scroll. + if (!hadLoadedRoot) { + setLoading(true) + } setError(null) } setDirectoryCache((prev) => ({ @@ -100,6 +112,37 @@ export function MobileFileExplorerPanel(props: { relativePath }) if (!response.ok) { + // Why: desktops that predate the files.readDir mobile allowlist + // entry still serve the capped files.list; fall back so the Files + // tab keeps working until the desktop updates. + if ( + rootLoad && + isMobileMethodUnavailableError(response.error?.code, response.error?.message) + ) { + const legacy = await client.sendRequest('files.list', { + worktree: `id:${worktreeId}` + }) + if (legacy.ok) { + if ( + !isCurrentDirectoryLoad( + directoryLoadRevisionsRef.current, + scopeRef.current, + loadToken + ) + ) { + return + } + const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult + setDirectoryCache(directoryCacheFromFileList(legacyResult.files)) + // Why: the capped list silently omits files past the cap — keep + // the legacy explorer's "Showing first 5000" note. + setLegacyListTruncated(legacyResult.truncated) + return + } + throw new Error( + legacy.error?.message || response.error?.message || 'Unable to load files' + ) + } throw new Error(response.error?.message || 'Unable to load files') } if ( @@ -108,6 +151,9 @@ export function MobileFileExplorerPanel(props: { return } const entries = (response as RpcSuccess).result as MobileDirEntry[] + if (rootLoad) { + setLegacyListTruncated(false) + } setDirectoryCache((prev) => ({ ...prev, [relativePath]: { entries } @@ -120,7 +166,9 @@ export function MobileFileExplorerPanel(props: { } const message = err instanceof Error ? err.message : 'Unable to load files' if (rootLoad) { - setError(message) + // Why: a failed background refresh keeps the cached tree browsable; + // only a cold load surfaces the full-screen error. + setError(hadLoadedRoot ? null : message) } else { setDirectoryCache((prev) => ({ ...prev, @@ -151,6 +199,7 @@ export function MobileFileExplorerPanel(props: { setExpanded(new Set()) setLoading(true) setError(null) + setLegacyListTruncated(false) }, [scope]) useEffect(() => { @@ -264,6 +313,7 @@ export function MobileFileExplorerPanel(props: { {worktreeLabel} + {legacyListTruncated ? ' - Showing first 5000' : ''} diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts new file mode 100644 index 00000000000..a69affc5fe8 --- /dev/null +++ b/mobile/src/files/file-list-fallback.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { getDirectoryCacheState } from './file-tree' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) + +describe('directoryCacheFromFileList', () => { + it('synthesizes every ancestor directory from flat paths', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual( + expect.arrayContaining([ + { name: 'src', isDirectory: true }, + { name: 'README.md', isDirectory: false } + ]) + ) + expect(cache['src']?.entries).toEqual( + expect.arrayContaining([ + { name: 'lib', isDirectory: true }, + { name: 'app.ts', isDirectory: false } + ]) + ) + expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) + }) + + it('keeps a name a directory when it appears as both file and dir prefix', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src', basename: 'src', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }]) + }) + + it('returns an empty root for an empty list', () => { + const cache = directoryCacheFromFileList([]) + expect(cache['']?.entries).toEqual([]) + }) + + it('stores a __proto__ directory as an own key instead of mutating the prototype', () => { + const cache = directoryCacheFromFileList([ + { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' } + ]) + expect(Object.getPrototypeOf(cache)).toBe(Object.prototype) + expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }]) + expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([ + { name: 'pollute.js', isDirectory: false } + ]) + }) +}) diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts new file mode 100644 index 00000000000..ef3041029af --- /dev/null +++ b/mobile/src/files/file-list-fallback.ts @@ -0,0 +1,73 @@ +// Fallback for desktops that predate files.readDir in the mobile RPC +// allowlist: synthesize the lazy directory cache from the flat, capped +// files.list result so the Files tab stays browsable against old desktops. +import type { DirectoryCache, MobileDirEntry } from './file-tree' + +export type LegacyMobileFileEntry = { + relativePath: string + basename: string + kind: 'text' | 'binary' +} + +export type LegacyFilesListResult = { + files: LegacyMobileFileEntry[] + totalCount: number + truncated: boolean +} + +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} + +export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache { + const childrenByDir = new Map>() + const ensureDir = (path: string): Map => { + let children = childrenByDir.get(path) + if (!children) { + children = new Map() + childrenByDir.set(path, children) + } + return children + } + ensureDir('') + for (const file of files) { + const parts = file.relativePath.split('/').filter(Boolean) + let parentPath = '' + parts.forEach((name, index) => { + const isDirectory = index < parts.length - 1 + const children = ensureDir(parentPath) + children.set(name, children.get(name) === true || isDirectory) + parentPath = parentPath ? `${parentPath}/${name}` : name + if (isDirectory) { + ensureDir(parentPath) + } + }) + } + // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the + // object's prototype instead of storing the directory; fromEntries always + // creates own keys. + return Object.fromEntries( + Array.from(childrenByDir, ([path, children]) => [ + path, + { + entries: Array.from( + children, + ([name, isDirectory]): MobileDirEntry => ({ + name, + isDirectory + }) + ) + } + ]) + ) +} From e30f9d36f287e797d8274f6276dbccb22e580a9f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:57:31 -0700 Subject: [PATCH 18/46] perf(renderer): purge seven per-tab/per-pty terminal maps on bulk worktree removal (#7613) --- ...-worktree-purge-terminal-maps-leak.test.ts | 127 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 34 +++++ 2 files changed, 161 insertions(+) create mode 100644 src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts new file mode 100644 index 00000000000..726e2b58fdc --- /dev/null +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -0,0 +1,127 @@ +/** + * Memory-leak regression: seven per-tab / per-pty terminal+agent store maps were + * evicted on the SINGLE removeWorktree path (via closeTab / shutdownWorktreeTerminals) + * but NOT on the BULK path — `buildWorktreePurgeState`, reached by removeProject, the + * external-worktree-removal authoritative-scan reconcile, and the hydration-stale + * reconcile. That path runs no terminal teardown, so before the fix each map stranded + * an entry per tab/pane of every externally-removed worktree for the renderer's whole + * session (unbounded across add/remove-repo cycles). tabId and ptyId are ephemeral, + * never-reused key spaces. + * + * Tab-keyed (evicted via the doomed-tab set): + * lastKnownRelayPtyIdByTabId, pendingInitialCwdByTabId, + * pendingIssueCommandSplitByTabId, pendingSetupSplitByTabId, pendingStartupByTabId + * Pty-keyed (evicted via the doomed-pty set derived from ptyIdsByTabId): + * codexRestartNoticeByPtyId, migrationUnsupportedByPtyId + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { list: vi.fn().mockResolvedValue([]), remove: vi.fn().mockResolvedValue(undefined) }, + pty: { kill: vi.fn().mockResolvedValue(undefined) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT1 = 'repo1::/path/wt1' +const WT2 = 'repo1::/path/wt2' +const TAB1 = 'tab-wt1' +const TAB2 = 'tab-wt2' +const PTY1 = 'pty-wt1' +const PTY2 = 'pty-wt2' + +function seedMaps(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT1]: [makeTab({ id: TAB1, worktreeId: WT1, ptyId: PTY1 })], + [WT2]: [makeTab({ id: TAB2, worktreeId: WT2, ptyId: PTY2 })] + }, + // Drives the doomed-ptyId derivation in buildWorktreePurgeState. + ptyIdsByTabId: { [TAB1]: [PTY1], [TAB2]: [PTY2] }, + lastKnownRelayPtyIdByTabId: { [TAB1]: PTY1, [TAB2]: PTY2 }, + pendingInitialCwdByTabId: { [TAB1]: '/path/wt1', [TAB2]: '/path/wt2' }, + pendingIssueCommandSplitByTabId: { + [TAB1]: { command: 'a' }, + [TAB2]: { command: 'b' } + }, + pendingSetupSplitByTabId: { + [TAB1]: { command: 'setup-a', direction: 'vertical' }, + [TAB2]: { command: 'setup-b', direction: 'vertical' } + }, + pendingStartupByTabId: { [TAB1]: { command: 'start-a' }, [TAB2]: { command: 'start-b' } }, + codexRestartNoticeByPtyId: { + [PTY1]: { previousAccountLabel: 'a1', nextAccountLabel: 'a2' }, + [PTY2]: { previousAccountLabel: 'b1', nextAccountLabel: 'b2' } + }, + migrationUnsupportedByPtyId: { + [PTY1]: { + ptyId: PTY1, + paneKey: `${TAB1}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + }, + [PTY2]: { + ptyId: PTY2, + paneKey: `${TAB2}:leaf-1`, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 2 + } + } + }) +} + +describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previously leaked', () => { + beforeEach(() => vi.clearAllMocks()) + + it('drops removed worktree entries (both tabId- and ptyId-keyed), retains survivors', () => { + const store = createTestStore() + seedMaps(store) + + store.getState().purgeWorktreeTerminalState([WT1]) + const s = store.getState() + + // Removed worktree's tab/pty: every map evicted. + expect(s.lastKnownRelayPtyIdByTabId[TAB1]).toBeUndefined() + expect(s.pendingInitialCwdByTabId[TAB1]).toBeUndefined() + expect(s.pendingIssueCommandSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingSetupSplitByTabId[TAB1]).toBeUndefined() + expect(s.pendingStartupByTabId[TAB1]).toBeUndefined() + expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined() + expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined() + + // Surviving worktree's tab/pty: every entry retained (no over-eviction). + expect(s.lastKnownRelayPtyIdByTabId[TAB2]).toBe(PTY2) + expect(s.pendingInitialCwdByTabId[TAB2]).toBe('/path/wt2') + expect(s.pendingIssueCommandSplitByTabId[TAB2]).toEqual({ command: 'b' }) + expect(s.pendingSetupSplitByTabId[TAB2]).toEqual({ command: 'setup-b', direction: 'vertical' }) + expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' }) + expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined() + expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() + }) +}) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 077deec381e..d6882728eea 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1829,12 +1829,20 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial() + // Why: some terminal/agent maps are keyed by ptyId, not tabId. Collect the + // doomed panes' ptyIds now (while ptyIdsByTabId is still populated) so this + // bulk path can evict them the same way shutdownWorktreeTerminals does on the + // single-removeWorktree path. + const doomedPtyIds = new Set() const doomedBrowserWorkspaceIds = new Set() const doomedPageIds = new Set() const removedFileIds = new Set() for (const id of worktreeIdSet) { for (const tab of s.tabsByWorktree[id] ?? []) { doomedTabIds.add(tab.id) + for (const ptyId of s.ptyIdsByTabId[tab.id] ?? []) { + doomedPtyIds.add(ptyId) + } // Why: a removed worktree's panes are gone for good, so drop their // hibernation output epochs from that module-level map (mirrors the // hosted-review prune above). A future pane mints a fresh leafId at epoch 0. @@ -1925,6 +1933,17 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial(obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const ptyId of doomedPtyIds) { + if (ptyId in out) { + delete out[ptyId] + changed = true + } + } + return changed ? out : obj + } // Pane-scoped maps are keyed by `${tabId}:${leafId}` (stable-pane-id); tabId // never contains ":", so the segment before the first ":" is the owning tab. const omitByPaneKeyTabPrefix = (obj: Record): Record => { @@ -2018,6 +2037,21 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial Date: Mon, 6 Jul 2026 18:03:36 -0700 Subject: [PATCH 19/46] feat(ai-vault): add OMP sessions to the AI Vault session browser (#7618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai-vault): add OMP sessions to the AI Vault session browser Adds OMP ("Oh My Pi") to the AI Vault/Agents catalog so historical .omp/agent/sessions/**/*.jsonl transcripts are discovered, parsed, and resumable from the right-sidebar session browser — locally and over SSH. - Discovery mirrors Pi (OMP_CODING_AGENT_DIR env, WSL home roots, per-agent limit) in both the local scanner and the remote/SSH scanner. - Parses OMP's message-graph JSONL via the shared graph parser (new MessageGraphAgent type), capturing the model from model_change.model (OMP's key, not Pi's modelId) or the assistant message, and tokens from usage. - Routes OMP through the incremental parse cache so ~5s rescans resume from the last byte instead of re-reading whole transcripts. - Resumes by absolute transcript path (`omp --resume `) so it resolves regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL store) the file was discovered under; threaded through both the scanner and the renderer's local resume/copy rebuild. - Renderer reuses the existing OmpIcon/catalog/grouping; adds overflow-x-hidden so long worktree chips never widen the sidebar. Generalizes normalizePiSessionsDir -> normalizeAgentSessionsDir. Verified end-to-end against 10 real ~/.omp transcripts and rendered in the app. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> * fix: make AI Vault parse-cache agent switch explicit --------- Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../remote-session-scanner-sources.ts | 18 ++- .../ai-vault/session-scanner-accumulator.ts | 1 + .../ai-vault/session-scanner-agent-parser.ts | 2 + .../session-scanner-codex-workers.test.ts | 1 + .../ai-vault/session-scanner-graph-parsers.ts | 17 ++- .../session-scanner-incremental-fixtures.ts | 51 ++++++++ ...canner-opencode-sqlite-coexistence.test.ts | 3 +- .../ai-vault/session-scanner-parse-cache.ts | 12 +- .../session-scanner-source-discovery.ts | 29 ++++- .../ai-vault/session-scanner-test-fixtures.ts | 1 + src/main/ai-vault/session-scanner-types.ts | 1 + .../ai-vault/session-scanner-values.test.ts | 17 +++ src/main/ai-vault/session-scanner-values.ts | 11 +- src/main/ai-vault/session-scanner.test.ts | 116 ++++++++++++------ .../AiVaultSessionVirtualList.tsx | 5 +- .../src/lib/ai-vault-resume-command.test.ts | 23 ++++ .../src/lib/ai-vault-resume-command.ts | 8 +- src/shared/ai-vault-resume-command.test.ts | 69 +++++++++++ src/shared/ai-vault-types.ts | 15 ++- 19 files changed, 338 insertions(+), 62 deletions(-) create mode 100644 src/shared/ai-vault-resume-command.test.ts diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index f232b0adf81..e8d43ae1f08 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -15,7 +15,7 @@ import { parseHermesSessionContent } from './session-scanner-secondary-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' import type { RemoteParserOptions, @@ -83,6 +83,7 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), jsonlSource( 'droid', remoteHome, @@ -203,6 +204,15 @@ function piParser( return parseMessageGraphSessionContent('pi', file, content, platform, options) } +function ompParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options) +} + function openClawParser( file: FileWithMtime, content: string, @@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] { } function remotePiSessionsSegments(): string[] { - return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) } diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index c31408a8496..8e44e30c60b 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -113,6 +113,7 @@ export function finalizeSession( resumeCommand: buildAiVaultResumeCommand({ agent: accumulator.agent, sessionId, + resumeFilePath: accumulator.filePath, cwd: accumulator.cwd, platform, codexHome: options.codexHome diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1114c1b8bb2..5a81ff260d0 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -64,6 +64,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('openclaw', candidate.file, platform) case 'pi': return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'omp': + return parseMessageGraphSessionFile('omp', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 585e37b0203..8a6fb183f62 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 01d84ddfac0..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str return extractContentText(textParts) } +// Agents whose transcripts are append-only message-graph JSONL (session + +// model_change + message records). OMP is a Pi fork and shares the format. +export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' + export async function parseMessageGraphSessionFile( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { @@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile( } export async function parseMessageGraphSessionContent( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, @@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st return } if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model + // Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress + // session shows its model before the first assistant reply lands. + accumulator.model = + extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model return } if (record.type !== 'message') { @@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st } export function createMessageGraphSessionResumeState( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { return accumulatorFoldResumeState( @@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState( } async function parseMessageGraphSessionLines(args: { - agent: 'openclaw' | 'pi' + agent: MessageGraphAgent file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 01219836a58..1945193e6c7 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture { } } +// OMP is a Pi fork sharing the message-graph format, but keys the model on +// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp' +// branch and that model-key difference. (The session `title` is included for +// realism only — the parser derives the title from the first user message.) +export function ompFixture(): IncrementalAgentFixture { + return { + agent: 'omp', + fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + title: 'OMP session title', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'omp incremental answer' }], + model: 'gpt-5.4-mini', + usage: { input: 40, output: 20, totalTokens: 60 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'cccccccc-dddd-4eee-8fff-000000000000', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + export function geminiJsonlFixture(): IncrementalAgentFixture { return { agent: 'gemini', @@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { droidFixture(), openclawFixture(), piFixture(), + ompFixture(), geminiJsonlFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index d0eaa649818..2fb3df21613 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) { piSessionsDir: join(root, 'pi-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + ompSessionsDir: join(root, 'omp-sessions') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index dcc7fc649aa..20650f105a9 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -59,7 +59,8 @@ function resumableStateFactoryFor( case 'droid': return () => createDroidSessionResumeState(candidate.file) case 'openclaw': - case 'pi': { + case 'pi': + case 'omp': { const agent = candidate.agent return () => createMessageGraphSessionResumeState(agent, candidate.file) } @@ -67,7 +68,12 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null - default: + case 'devin': + case 'grok': + case 'hermes': + case 'kimi': + case 'opencode': + case 'rovo': return null } } @@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void { /** * Parse a session file, reusing prior work where the file is provably * unchanged (mtime+size) and, for append-only JSONL transcripts (Claude, - * Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the + * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the * parse from the last consumed byte when the file only grew. This is what * keeps the renderer's ~5s forced rescans from re-reading gigabytes of * transcripts (STA-1278/STA-1417: main process pegging one core during diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 12acdeab188..8c03ec72e42 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join( const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizePiSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +const OMP_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' ) // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( @@ -124,7 +129,8 @@ function standardDiscoveries( ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues) + ...piDiscoveries(options, wslHomeDirs, limit, issues), + ...ompDiscoveries(options, wslHomeDirs, limit, issues) ] } @@ -244,6 +250,21 @@ function piDiscoveries( ) } +function ompDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]).map((rootDir) => + discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] }) + ) +} + function openClawDiscovery( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index b7c4dfd05cf..0a3b6c7c1a8 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7c6c38a0be3..5364e29e21e 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -25,6 +25,7 @@ export type AiVaultScanOptions = { openclawStateDir?: string openclawLegacyStateDir?: string piSessionsDir?: string + ompSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 9c1de341d28..a9954ae8d26 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { extractPreviewContentText, + normalizeAgentSessionsDir, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + + it('expands Pi and OMP agent homes to their session directories', () => { + expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe( + '/agents/.pi/agent/sessions' + ) + + expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + }) }) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 9733175e360..39006bd68a4 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null { return dirname(sessionRoot) } -export function normalizePiSessionsDir(rawValue: string): string { +// Pi and OMP (a Pi fork) both store transcripts under +// //agent/sessions; accept any prefix of that path. +export function normalizeAgentSessionsDir( + rawValue: string, + agentHomeDirName: '.pi' | '.omp' +): string { const trimmed = rawValue.trim() if (!trimmed) { - return join(homedir(), '.pi', 'agent', 'sessions') + return join(homedir(), agentHomeDirName, 'agent', 'sessions') } const normalized = trimmed.replace(/[\\/]+$/, '') const leaf = basename(normalized) @@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string { if (leaf === 'agent') { return join(normalized, 'sessions') } - if (leaf === '.pi') { + if (leaf === agentHomeDirName) { return join(normalized, 'agent', 'sessions') } return normalized diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 41b1b2b05ab..d4404341fc1 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => { ]) ) + const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl') + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + ompSessionFile, + jsonLines([ + { + type: 'session', + version: 3, + id: 'omp-session', + title: 'OMP session title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:08:30.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:31.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'OMP answer' }], + model: 'gpt-5.4-mini', + // totalTokens deliberately != input+output so the assertion proves + // the explicit-total field is read, not an input/output sum. + usage: { input: 10, output: 5, totalTokens: 160 } + } + } + ]) + ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( join(roots.devinTranscriptsDir, 'devin-session.json'), @@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + // OMP resumes by absolute transcript path, not by internal session id. + expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`) expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) + + const ompSession = result.sessions.find((session) => session.agent === 'omp') + expect(ompSession?.model).toBe('gpt-5.4-mini') + expect(ompSession?.totalTokens).toBe(160) + }) + + it('captures an in-progress OMP model from model_change before any assistant reply', async () => { + // OMP writes the model on `model_change.model` (not Pi's `modelId`). With no + // assistant message yet, the model must still come through — proving the + // model_change fallback rather than assistant-message capture. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + join(roots.ompSessionsDir, 'omp-in-progress.jsonl'), + jsonLines([ + { + type: 'session', + id: 'omp-in-progress', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/omp' + }, + { type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' }, + { + type: 'message', + timestamp: '2026-05-01T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 }) + const session = result.sessions.find((s) => s.agent === 'omp') + expect(session?.model).toBe('omp-mc-only-model') }) it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { @@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => { expect(usedGrokWrapperMatch).toBe(false) }) }) - -describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32' - }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') - }) - - it('carries non-default Codex homes in copied resume commands', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: '/repo/app', - platform: 'darwin', - codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' - }) - ).toBe( - "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" - ) - - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32', - codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' - }) - ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' - ) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index a8de4d84313..545b4095544 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -154,7 +154,10 @@ export function AiVaultSessionVirtualList({ }) return ( -
+
{loading && sessionsCount === 0 ? : null} {!loading && sessionsCount === 0 && !error ? ( diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index dc0345491bf..d6be594878e 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,6 +123,29 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) + it('resumes a local OMP session by its absolute transcript path, not its id', () => { + // Regression: local rebuilds must forward session.filePath so OMP resumes by + // path (not the internal id) even when the session lives in a non-default store. + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + const command = buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + + expect(command).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + ) + expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') + }) + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { // Regression guard: the copy path is self-contained for pasting into cmd.exe // and must stay cmd-wrapped even though the queued path now follows the shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 691d69efc09..ce23324c02a 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -26,7 +26,9 @@ type AiVaultResumeCommandSession = Pick< AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome' > & - Partial> + Partial< + Pick + > export type AiVaultResumeStartup = { command: string @@ -127,6 +129,10 @@ export function buildAiVaultResumeStartupForWorktree(args: { command: buildAiVaultResumeCommand({ agent: args.session.agent, sessionId: args.session.sessionId, + // Why: OMP resumes by absolute transcript path, so local rebuilds must + // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store + // session would resume by id against the default store and miss. + resumeFilePath: args.session.filePath, cwd: args.session.cwd, platform, commandOverride: args.commandOverride, diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts new file mode 100644 index 00000000000..101237ce1c3 --- /dev/null +++ b/src/shared/ai-vault-resume-command.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { buildAiVaultResumeCommand } from './ai-vault-types' + +describe('buildAiVaultResumeCommand', () => { + it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') + }) + + it('carries non-default Codex homes in copied resume commands', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' + }) + ).toBe( + "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" + ) + + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' + ) + }) + + it('resumes OMP by absolute transcript path so it resolves across session-dir roots', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: + '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl', + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe( + "cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/2026-07-03T11-30-29-357Z_019f27be/OmpScannerTests.jsonl'" + ) + }) + + it('falls back to the session id when no OMP transcript path is known', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: null, + cwd: '/Users/ada/repo', + platform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && omp --resume '019f27cd-4268-7000-96e7-62f42a55c144'") + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 58bf82597ab..4ba2c8820cb 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -12,6 +12,7 @@ export const AI_VAULT_AGENTS = [ 'codex', 'hermes', 'pi', + 'omp', 'cursor', 'gemini', 'rovo', @@ -34,6 +35,7 @@ export const AI_VAULT_AGENT_LABELS = { codex: 'Codex', hermes: 'Hermes', pi: 'Pi', + omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', rovo: 'Rovo Dev', @@ -102,10 +104,16 @@ export function buildAiVaultResumeCommand(args: { platform: NodeJS.Platform commandOverride?: string | null codexHome?: string | null + resumeFilePath?: string | null }): string { - const { agent, sessionId, cwd, platform, commandOverride, codexHome } = args + const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath } = args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) - const sessionArg = quoteShellArg(sessionId, platform) + // Why: OMP's `--resume` accepts an absolute transcript path, which resolves + // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL + // home) the file was discovered under, where an id-prefix lookup scoped to + // the default store would miss it. Falls back to the id if no path is known. + const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId + const sessionArg = quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome }) @@ -222,6 +230,9 @@ function buildAgentResumeInvocation( case 'devin': case 'openclaw': case 'droid': + // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), + // but the `--resume ` invocation form is identical to the others here. + case 'omp': return `${baseCommand} --resume ${sessionArg}` } } From 1886ea27f7300a46485e755e04b04bb6c00d9bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eren=20=C3=87akar?= Date: Thu, 2 Jul 2026 23:17:57 +0300 Subject: [PATCH 20/46] fix: use runtime agent IDs in workspace composer (#7082) --- src/renderer/src/hooks/useComposerState.ts | 31 ++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index dc6e1aa1957..d1316b98678 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1122,20 +1122,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [tuiAgent, setTuiAgent] = useState( persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent ) - // Why: when the selected repo is remote (has a connectionId), read the - // per-connection agent list instead of the local one. This ensures the - // Create Workspace dialog shows agents installed on the SSH host, not the - // local machine. + // Why: when the selected repo has a connectionId or runtime environment, read + // the per-host agent list instead of the local one. This ensures the Create + // Workspace dialog shows agents installed on the SSH host or paired runtime, + // not the local machine. const connectionId = selectedRepoConnectionId const isRemote = typeof connectionId === 'string' + const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null const detectedAgentList = useAppStore((s) => { if (isRemote) { return s.remoteDetectedAgentIds[connectionId] ?? null } + if (runtimeEnvironmentId) { + return s.runtimeDetectedAgentIds[runtimeEnvironmentId] ?? null + } return s.detectedAgentIds }) const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents) const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents) + const ensureRuntimeDetectedAgents = useAppStore((s) => s.ensureRuntimeDetectedAgents) const detectedAgentIds = useMemo | null>( () => (detectedAgentList ? new Set(detectedAgentList) : null), [detectedAgentList] @@ -1696,14 +1701,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ]) // Why: detect agents for the selected repo. For local repos this runs once - // on mount (deduped by the store). For remote repos it re-runs when the - // selected repo changes so the agent list matches the SSH host. + // on mount (deduped by the store). For remote/runtime repos it re-runs when + // the selected repo changes so the agent list matches the correct host. useEffect(() => { if (isRemote && selectedRepoSshStatus !== 'connected') { return } let cancelled = false - const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents() + const detect = isRemote + ? ensureRemoteDetectedAgents(connectionId) + : runtimeEnvironmentId + ? ensureRuntimeDetectedAgents(runtimeEnvironmentId) + : ensureDetectedAgents() void detect.then((ids) => { if (cancelled) { return @@ -1722,11 +1731,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return () => { cancelled = true } - // Why: re-run when connectionId changes (user picks a different repo) so - // detection targets the correct host. Draft/settings deps are intentionally - // excluded — detection is a best-effort PATH snapshot. + // Why: re-run when connectionId/runtimeEnvironmentId changes (user picks a + // different repo) so detection targets the correct host. Draft/settings deps + // are intentionally excluded — detection is a best-effort PATH snapshot. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connectionId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) + }, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents]) // Per-repo: load yaml hooks + issue command template. useEffect(() => { From d84041c52aa1cccb5493687ec2ce1f507b6eab2c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 6 Jul 2026 18:48:28 -0400 Subject: [PATCH 21/46] test: lock composer agent-detection host precedence for runtime repos (#7082) Regression guard for the runtime-agent-list fix: asserts the composer's detectedAgentList selector and detection effect resolve SSH -> runtime -> local in that order, so a runtime-owned repo shows the paired runtime's agents instead of the local machine's. Co-authored-by: Orca --- ...poserState-host-context-boundaries.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 00aa3fe4fea..51b552f3436 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -159,6 +159,40 @@ describe('useComposerState host-context boundaries', () => { expect(submitSection).toContain('runtimeEnvironmentId: folderTargetRuntimeEnvironmentId') }) + it('detects composer agents against the repo host: SSH, then runtime, then local (#7082)', () => { + // Why: a repo owned by a paired runtime must show the runtime's agents, not + // the local machine's. SSH stays first priority; runtime falls through before + // local so an SSH repo never double-detects. Regression guard for #7082. + const selectorSection = sourceBetween( + HOOK_SOURCE, + 'const detectedAgentList = useAppStore', + 'const ensureDetectedAgents = useAppStore' + ) + expect(selectorSection).toContain('if (isRemote) {') + expect(selectorSection).toContain('s.remoteDetectedAgentIds[connectionId]') + expect(selectorSection).toContain('if (runtimeEnvironmentId) {') + expect(selectorSection).toContain('s.runtimeDetectedAgentIds[runtimeEnvironmentId]') + expect(selectorSection).toContain('return s.detectedAgentIds') + // SSH branch is checked before the runtime branch. + expect(selectorSection.indexOf('if (isRemote) {')).toBeLessThan( + selectorSection.indexOf('if (runtimeEnvironmentId) {') + ) + + expect(HOOK_SOURCE).toContain( + 'const runtimeEnvironmentId = selectedRepoSettings?.activeRuntimeEnvironmentId?.trim() || null' + ) + + // Detection effect fans out to the same three hosts in the same order and + // re-runs when the runtime environment changes. + const detectSection = sourceBetween(HOOK_SOURCE, 'const detect = isRemote', 'void detect.then') + expect(detectSection).toContain('ensureRemoteDetectedAgents(connectionId)') + expect(detectSection).toContain('ensureRuntimeDetectedAgents(runtimeEnvironmentId)') + expect(detectSection).toContain('ensureDetectedAgents()') + expect(HOOK_SOURCE).toContain( + '}, [connectionId, runtimeEnvironmentId, isRemote, selectedRepoSshStatus, disabledTuiAgents])' + ) + }) + it('seeds initial workspace run target from the task source context', () => { expect( resolveInitialWorkspaceRunSeed({ From 51c3849a002529e50a02a920125bd65396ce2225 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:34:02 -0300 Subject: [PATCH 22/46] fix(github): route PR merges through repo owner (#7590) * fix(github): route PR merges through repo owner - Use repo-owner runtime settings before source-context overrides in PR actions - Keep headless/runtime repos on runtime RPC instead of local gh:mergePR - Add source-boundary coverage for merge routing and Windows-safe newline matching * fix(github): preserve repo-owner merge routing - Apply task-source runtime overrides only when they resolve to a runtime host - Keep runtime-owned repos from being downgraded to local gh:mergePR - Guard the routing contract with source-boundary coverage - Important because non-runtime source contexts could recreate the issue #6957 access-denied path * test(github): add behavioral regression test for repo-owner merge routing Mirrors PRActionsPanel's merge-routing decision through the real repo-runtime-owner/task-source helpers so a revert to source-only routing (issue #6957) fails here. Covers the runtime-owned repo with a local GitHub source view and the local-repo-while-runtime-focused case. Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../src/components/GitHubItemDialog.tsx | 16 ++++- .../github-item-dialog-merge-routing.test.ts | 71 +++++++++++++++++++ ...github-item-dialog-source-boundary.test.ts | 12 +++- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/github-item-dialog-merge-routing.test.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 863928204b0..5d45f1f78ea 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -3848,7 +3848,21 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = getTaskSourceRuntimeSettings(sourceContext) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null)) + ) + const sourceSettings = useMemo(() => { + if (sourceContext?.provider !== 'github') { + return repoOwnerSettings + } + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? ({ + ...repoOwnerSettings, + ...sourceRuntimeSettings + } as typeof repoOwnerSettings) + : repoOwnerSettings + }, [repoOwnerSettings, sourceContext]) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' diff --git a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts new file mode 100644 index 00000000000..8374fe94ba9 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + getSettingsForRepoRuntimeOwner, + type RepoRuntimeOwnerState +} from '../lib/repo-runtime-owner' +import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' +import type { TaskSourceContext } from '../../../shared/task-source-context' +import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context' + +// Why: mirrors the merge-routing decision inside GitHubItemDialog's +// PRActionsPanel so a revert to source-only routing (issue #6957) fails here. +// A runtime-owned repo whose GitHub source view is local must still merge on +// the owner runtime, not fall back to local `gh:mergePR`. +function resolveMergeTarget( + state: RepoRuntimeOwnerState, + repoId: string | null, + sourceContext: TaskSourceContext | null +): ReturnType { + const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId) + const sourceSettings = + sourceContext?.provider !== 'github' + ? repoOwnerSettings + : (() => { + const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext) + return sourceRuntimeSettings.activeRuntimeEnvironmentId + ? { ...repoOwnerSettings, ...sourceRuntimeSettings } + : repoOwnerSettings + })() + return getActiveRuntimeTarget(sourceSettings) +} + +function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext { + return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' } +} + +describe('GitHubItemDialog PR merge routing', () => { + const runtimeOwnedRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }] + } + + it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('routes a runtime-owned repo to its owner runtime when there is no source context', () => { + expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({ + kind: 'environment', + environmentId: 'owner-runtime' + }) + }) + + it('lets a runtime source override the repo owner when both are runtimes', () => { + expect( + resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime')) + ).toEqual({ kind: 'environment', environmentId: 'source-runtime' }) + }) + + it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => { + const localRepo: RepoRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + } + expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({ + kind: 'local' + }) + }) +}) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index deb287ca2d3..740d65f67cf 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -91,8 +91,8 @@ describe('GitHubItemDialog source host boundaries', () => { expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(matchInvalidationSection).toContain( - 'if (removed) {\n workItemDetailsCacheGeneration += 1' + expect(matchInvalidationSection).toMatch( + /if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/ ) }) @@ -190,7 +190,13 @@ describe('GitHubItemDialog source host boundaries', () => { 'function CommentReactions' ) - expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)') + expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId') + expect(actionsSection).toContain('...repoOwnerSettings') + expect(actionsSection).toContain( + 'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)' + ) + expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId') + expect(actionsSection).toContain('...sourceRuntimeSettings') expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)') expect(actionsSection).toContain( 'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ===' From 0b4196fc1786034b46fb24fd73b47eed21c4400e Mon Sep 17 00:00:00 2001 From: PP <128917720+huhuhulululu@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:35:42 -0400 Subject: [PATCH 23/46] fix(ssh): stop deleted ~/.ssh/config hosts from reappearing on sync (#7302) Deleting a config-sourced SSH target had no lasting effect: the Manage-SSH pane re-imports ~/.ssh/config on open, and the import was a pure upsert with no record of deletions, so the just-deleted host was re-inserted verbatim from the config that still exists on disk. Persist a `deletedSshConfigAliases` tombstone set: - Deleting a config-managed target (source 'ssh-config', or an adopted legacy import) records its alias; manual targets are never tombstoned. - The passive on-open sync skips tombstoned aliases, so a deleted host stays deleted. - Re-adding or editing a target reclaims its alias, and the explicit Import action (`reAdopt`) clears all tombstones to deliberately re-adopt config. This also fixes the edit-then-reappear case: editing a config host to `manual` already reserved its current alias, and reclaim covers alias changes. --- src/main/ipc/ssh.ts | 4 +- src/main/persistence.ts | 33 +++++ src/main/ssh/ssh-connection-store.test.ts | 115 ++++++++++++++++++ src/main/ssh/ssh-connection-store.ts | 52 +++++++- src/preload/api-types.ts | 2 +- src/preload/index.ts | 3 +- .../src/components/settings/SshPane.tsx | 5 +- src/shared/constants.ts | 1 + src/shared/types.ts | 4 + 9 files changed, 212 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..0ac0c5e0db0 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -714,8 +714,8 @@ export function registerSshHandlers( await removeRegisteredSshTarget(args.id) }) - ipcMain.handle('ssh:importConfig', () => { - return sshStore!.importFromSshConfig() + ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { + return sshStore!.importFromSshConfig(args) }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 157ab31f011..5bad9510e65 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3339,6 +3339,11 @@ export class Store { defaults.workspaceSession ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter( + (alias): alias is string => typeof alias === 'string' + ) + : [], sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), @@ -5973,6 +5978,34 @@ export class Store { this.scheduleSave() } + getDeletedSshConfigAliases(): string[] { + return [...(this.state.deletedSshConfigAliases ?? [])] + } + + addDeletedSshConfigAlias(alias: string): void { + this.state.deletedSshConfigAliases ??= [] + if (!this.state.deletedSshConfigAliases.includes(alias)) { + this.state.deletedSshConfigAliases.push(alias) + this.scheduleSave() + } + } + + removeDeletedSshConfigAlias(alias: string): void { + const current = this.state.deletedSshConfigAliases + if (!current || !current.includes(alias)) { + return + } + this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) + this.scheduleSave() + } + + clearDeletedSshConfigAliases(): void { + if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { + this.state.deletedSshConfigAliases = [] + this.scheduleSave() + } + } + // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 8e3e347d709..f1bf80d17aa 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({ function createMockStore() { const targets: SshTarget[] = [] + let deletedAliases: string[] = [] return { getSshTargets: vi.fn(() => [...targets]), @@ -32,6 +33,18 @@ function createMockStore() { if (idx !== -1) { targets.splice(idx, 1) } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] }) } } @@ -324,4 +337,106 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) }) + + describe('deleted config host tombstones', () => { + function candidate(overrides: Partial & { configHost: string }): SshTarget { + return { + id: `tmp-${overrides.configHost}`, + label: overrides.configHost, + host: `${overrides.configHost}.example.com`, + port: 22, + username: '', + ...overrides + } + } + + // PRIMARY regression: deleting a config-sourced host must not let the next + // ~/.ssh/config sync resurrect it. + it('tombstones a deleted config-sourced host so re-import skips it', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert + expect(result).toEqual([]) + }) + + it('does not tombstone a manual target on delete', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + }) + + it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.addTarget({ + label: 'mini', + configHost: 'mini', + host: '10.0.0.2', + port: 22, + username: 'ping' + }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + // Alias reclaimed, but it is now a manual target — still not re-inserted. + const result = sshStore.importFromSshConfig() + expect(result).toEqual([]) + }) + + it('editing a target reclaims its alias from the deleted set', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + }) + + it('reAdopt clears all tombstones and re-imports the deleted host', () => { + mockStore.addDeletedSshConfigAlias('mini') + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig({ reAdopt: true }) + + expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled() + expect(mockStore.addSshTarget).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'mini', source: 'ssh-config' }) + ) + expect(result).toHaveLength(1) + }) + }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index d65bd42b9f0..975da8cdbc2 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -23,6 +23,9 @@ export class SshConnectionStore { source: target.source ?? 'manual', id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } + // Why: re-adding a host the user previously deleted is an explicit intent to + // keep it — lift any tombstone so config sync stops suppressing this alias. + this.reclaimAlias(full.configHost ?? full.label) this.store.addSshTarget(full) return full } @@ -51,19 +54,49 @@ export class SshConnectionStore { } updateTarget(id: string, updates: Partial>): SshTarget | null { - return this.store.updateSshTarget(id, updates) + const updated = this.store.updateSshTarget(id, updates) + if (updated) { + // Why: actively editing a target reclaims its alias from the deleted set, + // so an edit can never leave the host tombstoned. + this.reclaimAlias(updated.configHost ?? updated.label) + } + return updated } removeTarget(id: string): void { + const target = this.store.getSshTarget(id) + // Why: deleting a config-managed target must record a tombstone; otherwise + // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still + // exists on disk) and the host reappears. Manual targets need no tombstone — + // sync never re-adds them. + if (target && isConfigManagedTarget(target)) { + const alias = target.configHost ?? target.label + if (alias) { + this.store.addDeletedSshConfigAlias(alias) + } + } this.store.removeSshTarget(id) } + private reclaimAlias(alias: string | undefined): void { + if (alias) { + this.store.removeDeletedSshConfigAlias(alias) + } + } + /** * Sync targets from ~/.ssh/config: insert new hosts, update existing * config-sourced ones in place (so a rotated port takes effect), never touch * manual targets. Returns the inserted and updated targets. */ - importFromSshConfig(): SshTarget[] { + importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { + // Why: the explicit Import action re-adopts every config host, so it clears + // all tombstones first. The passive on-open sync passes no flag and keeps + // deleted hosts suppressed. + if (options?.reAdopt) { + this.store.clearDeletedSshConfigAliases() + } + const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -102,6 +135,11 @@ export class SshConnectionStore { // A manual target owns this alias — never clobber it. continue } + if (deletedAliases.has(alias)) { + // The user deleted this config host — stay deleted until they re-add it + // or re-adopt config explicitly. + continue + } if (processedAliases.has(alias)) { continue } @@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } +function isConfigManagedTarget(target: SshTarget): boolean { + // Why: a target is subject to config sync (and therefore needs a tombstone on + // delete) when it is explicitly config-sourced, or a legacy import that sync + // still adopts. Manual targets are excluded — sync never re-adds them. + return ( + target.source === 'ssh-config' || + (target.source === undefined && isLegacyConfigImportTarget(target)) + ) +} + function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a6cf04e7d75..6387f62eb3e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2814,7 +2814,7 @@ export type PreloadApi = { updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: () => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ba7760a5bf9..b3dedf9e80e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3837,7 +3837,8 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'), + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:connect', args), diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 3a00df1d04e..1af4ba26f08 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -254,7 +254,10 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { const handleImport = async (): Promise => { try { - const synced = (await window.api.ssh.importConfig()) as SshTarget[] + // Why: the explicit Import action re-adopts every ~/.ssh/config host, + // including ones the user previously deleted — clear tombstones so a + // deliberate re-import can bring them back. + const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[] recordFeatureInteraction('ssh') if (mountedRef.current) { if (synced.length === 0) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 24aeeac4049..7e0aef0cda9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -436,6 +436,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { workspaceSession: getDefaultWorkspaceSession(), workspaceSessionsByHostId: {}, sshTargets: [], + deletedSshConfigAliases: [], sshRemotePtyLeases: [], claudeLivePtySessionIds: [], migrationUnsupportedPtyEntries: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index 5b84818af79..9c074ad2b43 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3498,6 +3498,10 @@ export type PersistedState = { * pre-partition builds keep working. Optional/absent on legacy files. */ workspaceSessionsByHostId?: Partial> sshTargets: SshTarget[] + /** SSH config aliases the user explicitly deleted. Suppresses re-import of the + * matching ~/.ssh/config host on the next sync so a deleted host does not + * reappear. Cleared for an alias when the user re-adds it or re-adopts config. */ + deletedSshConfigAliases: string[] sshRemotePtyLeases: SshRemotePtyLease[] /** Daemon session ids of live local Claude launches. Seeds the Claude * live-PTY gate on startup so an early OAuth refresh cannot rotate the From 4cbf6993608a6486d856bddb4ce5ff4c247616d0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:41:32 -0700 Subject: [PATCH 24/46] fix: quote queued OMP resumes for Windows shells (#7628) --- .../src/lib/ai-vault-resume-command.test.ts | 29 +++++++++++++++++-- .../src/lib/ai-vault-resume-command.ts | 3 +- src/shared/ai-vault-resume-command.test.ts | 15 ++++++++++ src/shared/ai-vault-types.ts | 10 +++++-- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index d6be594878e..4d5db5d7237 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,9 +123,9 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) - it('resumes a local OMP session by its absolute transcript path, not its id', () => { + it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { // Regression: local rebuilds must forward session.filePath so OMP resumes by - // path (not the internal id) even when the session lives in a non-default store. + // path, and queued Windows commands must match the live tab shell. const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) const command = buildAiVaultResumeCommandForWorktree({ @@ -141,11 +141,34 @@ describe('ai vault resume command runtime', () => { }) expect(command).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" ) expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) + it('keeps cmd quoting for local OMP resume when cmd.exe is configured', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + terminalWindowsShell: 'cmd.exe' + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && omp --resume ""C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"""' + ) + }) + it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => { // Regression guard: the copy path is self-contained for pasting into cmd.exe // and must stay cmd-wrapped even though the queued path now follows the shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index ce23324c02a..d71f3653fbb 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -136,7 +136,8 @@ export function buildAiVaultResumeStartupForWorktree(args: { cwd: args.session.cwd, platform, commandOverride: args.commandOverride, - codexHome + codexHome, + shell: queuedShell }) } } diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index 101237ce1c3..65fe12d4c99 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -55,6 +55,21 @@ describe('buildAiVaultResumeCommand', () => { ) }) + it('quotes queued OMP resume paths for the provided Windows shell', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + resumeFilePath: 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + shell: 'powershell' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\Users\\Ada Lovelace\\repo'; omp --resume 'C:\\Users\\Ada Lovelace\\.omp\\agent\\sessions\\repo\\sess.jsonl'" + ) + }) + it('falls back to the session id when no OMP transcript path is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 4ba2c8820cb..df6abfb8349 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -105,18 +105,22 @@ export function buildAiVaultResumeCommand(args: { commandOverride?: string | null codexHome?: string | null resumeFilePath?: string | null + shell?: AgentStartupShell }): string { - const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath } = args + const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath, shell } = + args const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent) // Why: OMP's `--resume` accepts an absolute transcript path, which resolves // regardless of which session-dir root (custom OMP_CODING_AGENT_DIR / WSL // home) the file was discovered under, where an id-prefix lookup scoped to // the default store would miss it. Falls back to the id if no path is known. const resumeTarget = agent === 'omp' && resumeFilePath?.trim() ? resumeFilePath.trim() : sessionId - const sessionArg = quoteShellArg(resumeTarget, platform) + const sessionArg = shell + ? quoteStartupArg(resumeTarget, shell) + : quoteShellArg(resumeTarget, platform) const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg) - return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome }) + return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome, shell }) } export function buildAiVaultResumeShellCommand(args: { From 86f404ef53c1d3f9299668ce0c400d92cc1a5654 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:08:42 -0700 Subject: [PATCH 25/46] perf(native-chat): byte-cap the transcript read-cache to bound RSS (GB-scale leak) (#7625) --- .../native-chat/transcript-read-cache.test.ts | 76 ++++++++++++++++++- src/main/native-chat/transcript-read-cache.ts | 42 ++++++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index df5b310fcef..6f0fc39acb6 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => { import { isTextBlock } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache, - readNativeChatTranscriptCached + readNativeChatTranscriptCached, + setNativeChatTranscriptCacheMaxBytesForTests } from './transcript-read-cache' let tempRoots: string[] = [] @@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise { return filePath } +// Writes a transcript file at an explicit path whose on-disk size is ~`bytes` +// (padded via one big user message), returning the path. Read it back by passing +// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME. +async function seedBigFile(name: string, bytes: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-')) + tempRoots.push(root) + const filePath = join(root, `${name}.jsonl`) + const record = { + type: 'user', + uuid: `u-${name}`, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) } + } + await writeFile(filePath, jsonLines([record])) + return filePath +} + beforeEach(() => { clearNativeChatTranscriptCache() + setNativeChatTranscriptCacheMaxBytesForTests() readSpy.mockClear() }) @@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => { // Distinct files must not share a cached parse object. expect(c).not.toBe(a) }) + + // Why: each cached entry is a FULL unwindowed transcript parse; heavy agent + // sessions are tens of MB, so the count-only cap let 50 entries retain multiple + // GB in the one process serving desktop + every paired client. The byte budget + // evicts the oldest large entries while always keeping the most-recent. + it('evicts the oldest entry once total cached bytes exceed the budget', async () => { + // ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest. + setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024) + const fileA = await seedBigFile('big-a', 4 * 1024) + const fileB = await seedBigFile('big-b', 4 * 1024) + const fileC = await seedBigFile('big-c', 4 * 1024) + + await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1 + await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2 + await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A + expect(readSpy).toHaveBeenCalledTimes(3) + + // C is the most-recent → still cached (no re-read). + await readNativeChatTranscriptCached('claude', 'sc', fileC) + expect(readSpy).toHaveBeenCalledTimes(3) + + // A was evicted by the byte cap → this re-reads (and evicts B in turn). + await readNativeChatTranscriptCached('claude', 'sa', fileA) + expect(readSpy).toHaveBeenCalledTimes(4) + + // B is now evicted → also re-reads. + await readNativeChatTranscriptCached('claude', 'sb', fileB) + expect(readSpy).toHaveBeenCalledTimes(5) + }) + + it('keeps a single active transcript larger than the whole budget cached', async () => { + // A lone entry over budget must NOT be dropped, else every read re-parses. + setNativeChatTranscriptCacheMaxBytesForTests(1024) + const big = await seedBigFile('huge', 8 * 1024) + await readNativeChatTranscriptCached('claude', 'huge', big) + await readNativeChatTranscriptCached('claude', 'huge', big) + expect(readSpy).toHaveBeenCalledTimes(1) + }) + + it('does not evict small entries under the default budget (no regression for typical use)', async () => { + const files = await Promise.all([ + seedBigFile('s1', 512), + seedBigFile('s2', 512), + seedBigFile('s3', 512) + ]) + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + // Re-read all three: every one is still cached (byte budget never triggered). + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index b62a1a1142a..9f4df5bde87 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -21,6 +21,9 @@ type CachedTranscript = { result: ReadTranscriptResult /** mtime of the resolved file when cached; a newer mtime invalidates it. */ mtimeMs: number + /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this + * entry's parsed memory footprint, used to bound the cache by total bytes. */ + bytes: number } const cache = new Map() @@ -28,19 +31,36 @@ const cache = new Map() // Why: cap the cache so a long-lived process browsing many sessions can't grow // it unbounded. Map preserves insertion order, so evicting the first key drops // the oldest entry (a simple LRU once re-inserts bump recency; see setCached). -// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling -// shows RSS pressure now that one process serves many remote clients. const MAX_CACHE_ENTRIES = 50 +// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool +// results embed whole file contents, command output, and diffs), and each cached +// entry is the full unwindowed parse. The count cap alone let 50 such entries +// retain multiple GB in the one process that now serves desktop + every paired +// web/mobile client. Bound total cached file bytes too; we always keep the most- +// recent entry (see setCached) so an active transcript is never re-parsed on +// every read, which caps the regression to extra re-parses only past this budget. +const MAX_CACHE_BYTES = 128 * 1024 * 1024 +// Overridable only from tests so the byte-eviction path can be exercised without +// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES. +let maxCacheBytes = MAX_CACHE_BYTES function setCached(key: string, value: CachedTranscript): void { // Re-insert moves the key to the most-recent position for LRU eviction. cache.delete(key) cache.set(key, value) - while (cache.size > MAX_CACHE_ENTRIES) { + let totalBytes = 0 + for (const entry of cache.values()) { + totalBytes += entry.bytes + } + // Evict oldest until within BOTH caps, but never drop the most-recent entry + // (cache.size > 1): a single active transcript larger than the whole budget + // must stay cached or every read would re-parse the full file. + while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { const oldest = cache.keys().next().value if (oldest === undefined) { break } + totalBytes -= cache.get(oldest)?.bytes ?? 0 cache.delete(oldest) } } @@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string { return `${agent}:${filePath}` } -async function fileMtimeMs(filePath: string): Promise { +async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { try { - return (await stat(filePath)).mtimeMs + const stats = await stat(filePath) + return { mtimeMs: stats.mtimeMs, bytes: stats.size } } catch { - return Number.NaN + return { mtimeMs: Number.NaN, bytes: 0 } } } @@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const mtimeMs = await fileMtimeMs(filePath) + const { mtimeMs, bytes } = await fileStat(filePath) const cached = cache.get(key) if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. @@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached( const result = await readNativeChatTranscript(agent, sessionId, { filePath }) if (Number.isFinite(mtimeMs)) { - setCached(key, { result, mtimeMs }) + setCached(key, { result, mtimeMs, bytes }) } return result } @@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached( export function clearNativeChatTranscriptCache(): void { cache.clear() } + +/** Test-only: override the byte budget (pass no arg to restore the default). */ +export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void { + maxCacheBytes = bytes ?? MAX_CACHE_BYTES +} From a6cbd3102a6fd589b74e0b7b809f154c1197c143 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:17:35 -0700 Subject: [PATCH 26/46] fix(terminal): repaint revealed panes stuck behind xterm's paused-render gate (#7614) Co-authored-by: Orca --- .../lib/pane-manager/pane-webgl-renderer.ts | 14 +- .../terminal-render-pause-release.test.ts | 87 ++++++ .../terminal-render-pause-release.ts | 73 +++++ ...erminal-reveal-paused-render-repro.spec.ts | 268 ++++++++++++++++++ 4 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts create mode 100644 tests/e2e/terminal-reveal-paused-render-repro.spec.ts diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index d7c80e76220..e0ca805801e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -126,9 +127,16 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. pane.webglAddon?.clearTextureAtlas() - // Why: refresh even without a WebGL addon so recovery never silently - // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: on reveal xterm's IntersectionObserver can still report the pane as + // not intersecting, so a plain refresh() is swallowed by RenderService's + // paused-render gate and the cleared model never repaints (stale bottom rows + // until a drag-select forces a redraw). Force the paused render through + // first; only fall back to refresh() when the terminal was not gated. + if (!forceRepaintThroughRenderPause(pane.terminal)) { + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. + pane.terminal.refresh(0, pane.terminal.rows - 1) + } } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts new file mode 100644 index 00000000000..e3d84677424 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' + +type FakeRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: ReturnType +} + +function createTerminal(options: { + rows?: number + renderService?: FakeRenderService | null + withoutCore?: boolean +}): unknown { + const { rows = 24, renderService, withoutCore } = options + if (withoutCore) { + return { rows } + } + return { + rows, + _core: { _renderService: renderService ?? null } + } +} + +describe('forceRepaintThroughRenderPause', () => { + it('drives a synchronous full-viewport render and clears the pause latches when paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows + } + const terminal = createTerminal({ rows: 30, renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 29, true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) + + it('leaves the terminal untouched and returns false when not paused', () => { + const refreshRows = vi.fn() + const renderService: FakeRenderService = { + _isPaused: false, + _needsFullRefresh: false, + refreshRows + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the render service internals are unavailable', () => { + expect(forceRepaintThroughRenderPause(createTerminal({ withoutCore: true }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: null }))).toBe(false) + expect(forceRepaintThroughRenderPause(createTerminal({ renderService: {} }))).toBe(false) + expect(forceRepaintThroughRenderPause(null)).toBe(false) + }) + + it('returns false without rendering when the row count is invalid', () => { + const refreshRows = vi.fn() + const terminal = createTerminal({ + rows: 0, + renderService: { _isPaused: true, refreshRows } + }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('returns false when the forced render throws (disposed mid-frame)', () => { + const renderService: FakeRenderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows: vi.fn(() => { + throw new Error('terminal disposed') + }) + } + const terminal = createTerminal({ renderService }) + + expect(forceRepaintThroughRenderPause(terminal)).toBe(false) + // Latch is still cleared — the observer reasserts authority on its next + // callback, and we must not leave a half-serviced full-refresh queued. + expect(renderService._isPaused).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts new file mode 100644 index 00000000000..88b53129d87 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -0,0 +1,73 @@ +/** + * Forces a full synchronous repaint through xterm's RenderService even when its + * IntersectionObserver still reports the screen element as not intersecting. + * + * Why: on tab/worktree reveal the pane is DOM-visible but xterm's own + * observer callback can lag a frame (worse under load), leaving + * `RenderService._isPaused === true`. While paused, `refreshRows` early-returns + * and only latches `_needsFullRefresh`, so the reveal-repaint's + * `terminal.refresh()` is swallowed — the freshly-cleared render model never + * repaints and the canvas keeps compositing stale rows (classic "bottom rows + * missing until you drag-select" symptom). We can't wait for the observer, so + * we clear the latch and drive one synchronous full render ourselves; the + * observer reasserts authority naturally on its next callback. + * + * All access is behind typeof guards: an xterm upgrade that renames these + * internals degrades to a no-op (callers keep their existing refresh path), it + * never throws into a render frame. + */ + +type MaybePausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + refreshRows?: (start: number, end: number, sync?: boolean) => void +} + +type PausableRenderService = MaybePausableRenderService & { + refreshRows: (start: number, end: number, sync?: boolean) => void +} + +type TerminalWithRenderService = { + rows?: number + _core?: { + _renderService?: MaybePausableRenderService + } +} + +function getRenderService(terminal: unknown): PausableRenderService | null { + const service = (terminal as TerminalWithRenderService | null)?._core?._renderService + return service && typeof service.refreshRows === 'function' + ? (service as PausableRenderService) + : null +} + +/** + * If xterm's renderer is paused (observer hasn't caught up to the reveal), + * clear the pause latch and force a synchronous full-viewport repaint. + * Returns true when it drove the render, false when it left the terminal + * untouched (not paused, or internals unavailable) so the caller can fall back + * to its normal `terminal.refresh()`. + */ +export function forceRepaintThroughRenderPause(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service || service._isPaused !== true) { + return false + } + + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + // Why: leave the latch as if the pending full refresh was serviced — we are + // about to service it — so the observer's next callback doesn't queue a + // redundant second full repaint. + service._isPaused = false + service._needsFullRefresh = false + try { + service.refreshRows(0, rows - 1, true) + return true + } catch { + return false + } +} diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts new file mode 100644 index 00000000000..5b280037da4 --- /dev/null +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -0,0 +1,268 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveTabId, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' + +/** + * Reproduction for the "missing bottom rows on reveal, recover on drag-select" + * bug (PR #7614). The mechanism is xterm's RenderService gating refreshRows() on + * its IntersectionObserver: while `_isPaused` is true (the observer can lag a + * frame behind a just-revealed pane, worse under load), refresh() early-returns + * and only latches `_needsFullRefresh`. The reveal-repaint's terminal.refresh() + * is then swallowed and the freshly-cleared render model never repaints. + * + * This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases + * -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real + * xterm Terminal + RenderService. It: + * 1. proves the bug: while paused, a plain refresh() renders nothing; + * 2. proves the fix: the real reveal repaint forces a full-viewport render + * through the paused gate and clears the pause latch; + * 3. confirms recovery at the pixel level. + * + * The paused state is set deterministically rather than raced, because headless + * Electron does not reliably reproduce the observer-lag timing (documented for + * this bug class). The gate we force is the exact one the field bug hits. + */ + +const BOTTOM_MARKER = 'REVEAL_PAUSED_RENDER_BOTTOM_MARKER' + +type RenderProbeResult = { + paused: boolean + renderedRanges: [number, number][] + rows: number +} + +type RevealRenderDebug = { + installProbe: () => boolean + setPaused: (paused: boolean) => boolean + dirtyModelLikeReveal: () => boolean + plainRefresh: () => void + runRealRevealRepaint: () => void + read: () => RenderProbeResult +} + +type RevealProbeWindow = Window & { + __revealRenderProbe?: RevealRenderDebug +} + +/** + * Installs an in-page probe that instruments the active pane's REAL xterm + * RenderService. Everything here runs against production objects; the only + * test-only code is the recording wrapper around `_renderRows` and the manual + * flip of `_isPaused` that stands in for the observer-lag race. + */ +async function installRevealRenderProbe(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!manager || !pane) { + throw new Error('No active pane for reveal render probe') + } + + type PausableRenderService = { + _isPaused?: boolean + _needsFullRefresh?: boolean + _renderRows?: (start: number, end: number) => void + __revealProbeRanges?: [number, number][] + __revealProbeInstalled?: boolean + } + type TerminalInternals = { + rows?: number + _core?: { _renderService?: PausableRenderService } + buffer?: { active?: { getLine?: (y: number) => unknown } } + refresh?: (start: number, end: number) => void + } + + const terminal = pane.terminal as unknown as TerminalInternals + const service = terminal._core?._renderService + if (!service || typeof service._renderRows !== 'function') { + throw new Error('Real RenderService._renderRows unavailable — cannot probe') + } + + const debug: RevealRenderDebug = { + installProbe: () => { + if (service.__revealProbeInstalled) { + service.__revealProbeRanges = [] + return true + } + service.__revealProbeRanges = [] + const original = service._renderRows!.bind(service) + service._renderRows = (start: number, end: number): void => { + service.__revealProbeRanges!.push([start, end]) + original(start, end) + } + service.__revealProbeInstalled = true + return true + }, + setPaused: (paused: boolean) => { + service._isPaused = paused + return service._isPaused === paused + }, + dirtyModelLikeReveal: () => { + // Why: reveal clears the WebGL render model so a full rebuild is forced. + // clearTextureAtlas() routes through RenderService and, crucially, also + // requests a redraw — which is exactly what the paused gate then eats. + const withAtlas = pane as unknown as { + webglAddon?: { clearTextureAtlas?: () => void } + } + withAtlas.webglAddon?.clearTextureAtlas?.() + return true + }, + plainRefresh: () => { + const rows = terminal.rows ?? 0 + terminal.refresh?.(0, Math.max(0, rows - 1)) + }, + runRealRevealRepaint: () => { + // The real production reveal path — contains the fix under test. + manager.resetWebglTextureAtlases() + }, + read: () => ({ + paused: service._isPaused === true, + renderedRanges: (service.__revealProbeRanges ?? []).slice(), + rows: terminal.rows ?? 0 + }) + } + + ;(window as RevealProbeWindow).__revealRenderProbe = debug + }, tabId) +} + +async function probeRead(page: Page): Promise { + return page.evaluate(() => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + return probe.read() + }) +} + +async function probeCall( + page: Page, + method: + | 'installProbe' + | 'setPaused' + | 'dirtyModelLikeReveal' + | 'plainRefresh' + | 'runRealRevealRepaint', + paused?: boolean +): Promise { + await page.evaluate( + ({ method, paused }) => { + const probe = (window as RevealProbeWindow).__revealRenderProbe + if (!probe) { + throw new Error('Reveal render probe not installed') + } + if (method === 'setPaused') { + probe.setPaused(paused ?? false) + return + } + ;(probe[method] as () => void)() + }, + { method, paused } + ) +} + +async function forceWebglOn(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + if (state?.settings) { + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + } + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, tabId) +} + +test.describe('terminal reveal paused-render recovery', () => { + test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { + // Why: __store / __paneManagers live on the main Orca renderer window + // (orcaPage), not Playwright's default first page. + const page = orcaPage + await waitForSessionReady(page) + await waitForActiveTerminalManager(page) + const tabId = (await getActiveTabId(page))! + const ptyId = await waitForActivePanePtyId(page) + + await forceWebglOn(page, tabId) + + // Fill the viewport down to the bottom so a swallowed repaint is observable + // both in the render ranges and on-screen. + await execInTerminal( + page, + ptyId, + `for i in $(seq 1 40); do echo "line $i ${BOTTOM_MARKER}_$i"; done` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_40`) + // Clear-screen + reprint so the marker sits on the real bottom rows. + await execInTerminal( + page, + ptyId, + `clear; printf '%s\\n' "$(seq 1 30)"; echo "${BOTTOM_MARKER}_FINAL"` + ) + await waitForTerminalOutput(page, `${BOTTOM_MARKER}_FINAL`) + + const revealed = await captureStableTabScreenshot(page, tabId) + + await installRevealRenderProbe(page, tabId) + await probeCall(page, 'installProbe') + + // ---- Control: prove the bug. This sequence (clearTextureAtlas + refresh) + // is byte-for-byte the PRE-FIX resetWebglTextureAtlas on origin/main, so it + // faithfully replays the old reveal path. While paused, it renders nothing. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'plainRefresh') + // Give any (non-existent) queued render a frame to land. + await page.waitForTimeout(80) + const afterPlainRefresh = await probeRead(page) + + expect(afterPlainRefresh.paused, 'terminal is in the paused-render state').toBe(true) + expect( + afterPlainRefresh.renderedRanges, + 'BUG REPRODUCED: while paused, plain refresh() is swallowed by the gate — no render fires' + ).toHaveLength(0) + + // ---- Fix: the real reveal repaint must force a full render through the gate. + await probeCall(page, 'setPaused', true) + await probeCall(page, 'dirtyModelLikeReveal') + await probeCall(page, 'runRealRevealRepaint') + await page.waitForTimeout(80) + const afterRealReveal = await probeRead(page) + + expect( + afterRealReveal.renderedRanges.length, + 'FIX: reveal repaint drove at least one render despite the paused gate' + ).toBeGreaterThan(0) + + const fullViewportRender = afterRealReveal.renderedRanges.some( + ([start, end]) => start === 0 && end >= afterRealReveal.rows - 1 + ) + expect( + fullViewportRender, + 'FIX: reveal repaint rendered the FULL viewport (0..rows-1), not a partial range' + ).toBe(true) + + expect( + afterRealReveal.paused, + 'FIX: pause latch is cleared so the observer can reassert authority cleanly' + ).toBe(false) + + // ---- Pixel-level confirmation: the surface still shows the correct content + // after being driven through the paused gate (no stale/blank bottom rows). + const afterFix = await captureStableTabScreenshot(page, tabId) + const diff = compareTerminalScreenshots(revealed, afterFix) + expect( + diff.matches, + `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` + ).toBe(true) + }) +}) From 98d850aebae9f9e87ab20add8fd9cb53f33101a0 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:21:58 +0200 Subject: [PATCH 27/46] fix(settings): keep full installed font list (#7581) * fix(settings): keep full installed font list * fix(settings): bound font picker rendering * Fix redundant font queries and handle partial state in worktree purge - Latch the loaded state even when the returned font list is empty, preventing font-less systems from reissuing font listing calls on every picker interaction. - Add null-tolerance for `ptyIdsByTabId` when building worktree purge state to support callers that pass partial AppState. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/system-fonts.test.ts | 57 ++++++++++++- src/main/system-fonts.ts | 45 ++++++---- .../settings/AppearanceInterfaceSection.tsx | 3 + .../settings/AppearancePane.test.tsx | 25 +++++- .../components/settings/AppearancePane.tsx | 4 + .../src/components/settings/Settings.tsx | 78 ++++++++++-------- .../settings/SettingsConstants.test.ts | 14 ++++ .../components/settings/SettingsConstants.ts | 11 ++- ...ngsFormControls.font-autocomplete.test.tsx | 82 +++++++++++++++++++ .../settings/SettingsFormControls.tsx | 31 +++++-- .../settings/TerminalAppearanceSection.tsx | 3 + .../settings-form-option-filter.test.ts | 35 ++++++++ .../settings/settings-form-option-filter.ts | 23 ++++++ src/renderer/src/store/slices/worktrees.ts | 4 +- 14 files changed, 353 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/settings/SettingsConstants.test.ts diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 3f63d8930f0..c6c5e0e42c4 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -9,16 +9,56 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) -function expectedFallbackFont(): string { - if (process.platform === 'darwin') { +function expectedFallbackFont(platform = process.platform): string { + if (platform === 'darwin') { return 'SF Mono' } - if (process.platform === 'win32') { + if (platform === 'win32') { return 'Cascadia Mono' } return 'JetBrains Mono' } +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +async function expectFontCommandTimeout( + platform: NodeJS.Platform, + timeoutMs: number +): Promise { + await withPlatform(platform, async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(timeoutMs - 1) + + expect(resolvedFonts).toBeNull() + expect(killMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform)) + expect(killMock).toHaveBeenCalledOnce() + }) +} + describe('listSystemFontFamilies', () => { afterEach(() => { vi.useRealTimers() @@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => { expect(resolvedFonts).toContain(expectedFallbackFont()) expect(killMock).toHaveBeenCalledOnce() }) + + it('uses the longer timeout for macOS profiler scans', async () => { + await expectFontCommandTimeout('darwin', 45_000) + }) + + it.each([ + ['linux' as NodeJS.Platform, 15_000], + ['win32' as NodeJS.Platform, 15_000] + ])('keeps the %s font command timeout short', async (platform, timeoutMs) => { + await expectFontCommandTimeout(platform, timeoutMs) + }) }) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index afe142562d9..15ff9c28bde 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -3,6 +3,9 @@ import { execFile } from 'node:child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 +// Why: large macOS font catalogs can make system_profiler exceed 15s even +// when it is healthy; keep the longer wait scoped to that slow command. +const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise { } function listMacFonts(): Promise { - return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then( - (output) => { - const parsed = JSON.parse(output) as { - SPFontsDataType?: { - typefaces?: { - family?: string - }[] + return execFileText( + 'system_profiler', + ['SPFontsDataType', '-json'], + 32 * 1024 * 1024, + MAC_SYSTEM_FONT_LIST_TIMEOUT_MS + ).then((output) => { + const parsed = JSON.parse(output) as { + SPFontsDataType?: { + typefaces?: { + family?: string }[] - } - - return uniqueSorted( - (parsed.SPFontsDataType ?? []).flatMap((font) => - (font.typefaces ?? []).map((typeface) => typeface.family) - ) - ) + }[] } - ) + + return uniqueSorted( + (parsed.SPFontsDataType ?? []).flatMap((font) => + (font.typefaces ?? []).map((typeface) => typeface.family) + ) + ) + }) } function listLinuxFonts(): Promise { @@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name } ) } -function execFileText(command: string, args: string[], maxBuffer: number): Promise { +function execFileText( + command: string, + args: string[], + maxBuffer: number, + timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS +): Promise { return new Promise((resolve, reject) => { let settled = false let timer: ReturnType | undefined @@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi // should fall back instead of keeping settings IPC pending forever. child.kill() reject(new Error(`Timed out listing system fonts with ${command}`)) - }, SYSTEM_FONT_LIST_TIMEOUT_MS) + }, timeoutMs) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 0f691a072dd..31afc7d6843 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -38,6 +38,7 @@ type AppearanceInterfaceSectionProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] isDesktopWindows: boolean + onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean } @@ -47,6 +48,7 @@ export function AppearanceInterfaceSection({ applyTheme, fontSuggestions, isDesktopWindows, + onRequestFontSuggestions, forceVisiblePrimary = false }: AppearanceInterfaceSectionProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) @@ -142,6 +144,7 @@ export function AppearanceInterfaceSection({ value={settings.appFontFamily} suggestions={fontSuggestions} placeholder={DEFAULT_APP_FONT_FAMILY} + onRequestSuggestions={onRequestFontSuggestions} onChange={(value) => updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY }) } diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 714844932f1..5fe949cea39 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -129,7 +129,10 @@ function createWarpThemesStub() { async function renderAppearancePane( settings: GlobalSettings, - updateSettings: (updates: Partial) => void = vi.fn() + updateSettings: (updates: Partial) => void = vi.fn(), + options: { + onRequestFontSuggestions?: () => void + } = {} ): Promise { const container = document.createElement('div') document.body.appendChild(container) @@ -146,6 +149,7 @@ async function renderAppearancePane( applyTheme={vi.fn()} fontSuggestions={[]} terminalFontSuggestions={[]} + onRequestFontSuggestions={options.onRequestFontSuggestions} systemPrefersDark={false} ghostty={createGhosttyStub() as never} warpThemes={createWarpThemesStub() as never} @@ -296,6 +300,25 @@ describe('AppearancePane', () => { expect(container.textContent).not.toContain('Code & Markdown') }) + it('requests installed font suggestions only after the IDE font picker is used', async () => { + mocks.state.settingsSearchQuery = '' + const requestFontSuggestions = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), vi.fn(), { + onRequestFontSuggestions: requestFontSuggestions + }) + + expect(requestFontSuggestions).not.toHaveBeenCalled() + + const input = container.querySelector('input[role="combobox"]') + expect(input).not.toBeNull() + + await act(async () => { + input?.focus() + }) + + expect(requestFontSuggestions).toHaveBeenCalledOnce() + }) + it('keeps the app icon control at the bottom of the pane, after the section rows', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index f7a2dd52c13..92792621533 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -45,6 +45,7 @@ type AppearancePaneProps = { applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] terminalFontSuggestions: string[] + onRequestFontSuggestions?: () => void systemPrefersDark: boolean ghostty: UseGhosttyImportReturn warpThemes: UseWarpThemeImportReturn @@ -68,6 +69,7 @@ export function AppearancePane({ applyTheme, fontSuggestions, terminalFontSuggestions, + onRequestFontSuggestions, systemPrefersDark, ghostty, warpThemes @@ -179,6 +181,7 @@ export function AppearancePane({ updateSettings={updateSettings} applyTheme={applyTheme} fontSuggestions={fontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> @@ -205,6 +208,7 @@ export function AppearancePane({ updateSettings={updateSettings} systemPrefersDark={systemPrefersDark} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={onRequestFontSuggestions} ghostty={ghostty} warpThemes={warpThemes} forceVisiblePrimary={terminalLabelMatches} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 92c4a35b2c4..bfe82c9d783 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -23,7 +23,11 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' -import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants' +import { + SCROLLBACK_PRESETS_ROWS, + getFallbackTerminalFonts, + mergeFontSuggestions +} from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { GeneralPane } from './GeneralPane' @@ -313,7 +317,7 @@ function Settings(): React.JSX.Element { const ghostty = useGhosttyImport(updateSettings, settings) const warpThemes = useWarpThemeImport(updateSettings, settings) const [fontSuggestions, setFontSuggestions] = useState( - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()])) + mergeFontSuggestions([], getFallbackTerminalFonts()) ) const terminalFontSuggestions = useMemo( () => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY), @@ -336,7 +340,9 @@ function Settings(): React.JSX.Element { const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false) const contentScrollRef = useRef(null) const searchInputRef = useRef(null) - const terminalFontsLoadedRef = useRef(false) + const installedFontsLoadedRef = useRef(false) + const installedFontsLoadPromiseRef = useRef | null>(null) + const settingsMountedRef = useRef(true) const pendingNavSectionRef = useRef(null) const pendingScrollTargetRef = useRef(null) const pendingSubsectionScrollFrameRef = useRef(null) @@ -394,6 +400,42 @@ function Settings(): React.JSX.Element { cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef) }, []) + useEffect(() => { + // Why: React dev StrictMode replays mount effects; async font requests + // should still commit while the Settings view is actually mounted. + settingsMountedRef.current = true + return () => { + settingsMountedRef.current = false + } + }, []) + + const requestFontSuggestions = useCallback((): void => { + if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) { + return + } + + installedFontsLoadPromiseRef.current = window.api.settings + .listFonts() + .then((fonts) => { + if (!settingsMountedRef.current) { + return + } + // Latch after the first successful attempt even when empty, so a font-less + // system doesn't reissue listFonts() on every picker interaction. + installedFontsLoadedRef.current = true + if (fonts.length === 0) { + return + } + setFontSuggestions((prev) => mergeFontSuggestions(fonts, prev)) + }) + .catch(() => { + // Fall back to curated cross-platform suggestions. + }) + .finally(() => { + installedFontsLoadPromiseRef.current = null + }) + }, []) + // Pure "discard and leave?" prompt — no side effects. Why separate from the // discard helper below: the window-close guard must ask without clearing the // drafts, since a later guard/handler can still cancel the close. @@ -754,35 +796,6 @@ function Settings(): React.JSX.Element { setMountedSectionIds(neededSectionIds) } - useEffect(() => { - if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) { - return - } - if (terminalFontsLoadedRef.current) { - return - } - - let stale = false - const loadFontSuggestions = async (): Promise => { - try { - const fonts = await window.api.settings.listFonts() - if (stale || fonts.length === 0) { - return - } - terminalFontsLoadedRef.current = true - setFontSuggestions((prev) => - Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320) - ) - } catch { - // Fall back to curated cross-platform suggestions. - } - } - void loadFontSuggestions() - return () => { - stale = true - } - }, [neededSectionIds]) - const neededRepoIds = useMemo( () => deriveNeededRepoIds(repos, neededSectionIds), [neededSectionIds, repos] @@ -1410,6 +1423,7 @@ function Settings(): React.JSX.Element { applyTheme={applyTheme} fontSuggestions={fontSuggestions} terminalFontSuggestions={terminalFontSuggestions} + onRequestFontSuggestions={requestFontSuggestions} systemPrefersDark={systemPrefersDark} ghostty={ghostty} warpThemes={warpThemes} diff --git a/src/renderer/src/components/settings/SettingsConstants.test.ts b/src/renderer/src/components/settings/SettingsConstants.test.ts new file mode 100644 index 00000000000..46876796784 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsConstants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' +import { mergeFontSuggestions } from './SettingsConstants' + +describe('mergeFontSuggestions', () => { + it('keeps installed fonts beyond the old hydration cutoff', () => { + const systemFonts = Array.from({ length: 350 }, (_value, index) => `System Font ${index}`) + const suggestions = mergeFontSuggestions(systemFonts, ['JetBrains Mono']) + + expect(suggestions).toContain(DEFAULT_APP_FONT_FAMILY) + expect(suggestions).toContain('System Font 349') + expect(suggestions).toContain('JetBrains Mono') + }) +}) diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts index 47fb0ba281a..2c49121d31d 100644 --- a/src/renderer/src/components/settings/SettingsConstants.ts +++ b/src/renderer/src/components/settings/SettingsConstants.ts @@ -1,5 +1,5 @@ import type { OrcaHooks } from '../../../../shared/types' -import { getDefaultRepoHookSettings } from '../../../../shared/constants' +import { DEFAULT_APP_FONT_FAMILY, getDefaultRepoHookSettings } from '../../../../shared/constants' import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy' export type HookName = keyof OrcaHooks['scripts'] @@ -14,6 +14,15 @@ export function zoomLevelToPercent(level: number): number { return Math.round(100 * Math.pow(1.2, level)) } +export function mergeFontSuggestions( + systemFonts: readonly string[], + previousFonts: readonly string[] +): string[] { + // Why: picker rendering can be capped later, but the source list must keep + // every installed font searchable/selectable. + return Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...systemFonts, ...previousFonts])) +} + export function getFallbackTerminalFonts(): string[] { const nav = typeof navigator !== 'undefined' diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index 62412f2e258..4e5e3a8f235 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FontAutocomplete } from './SettingsFormControls' +import { FONT_SUGGESTION_RENDER_LIMIT } from './settings-form-option-filter' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, defaultValue: string) => defaultValue @@ -186,4 +187,85 @@ describe('FontAutocomplete', () => { expect(getOptionLabels()).toEqual(['JetBrains Mono']) }) + + it('requests installed suggestions only after the picker is used', async () => { + const requestSuggestions = vi.fn() + + function Harness(): ReactNode { + const [value, setValue] = useState('Geist') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + expect(requestSuggestions).not.toHaveBeenCalled() + + await act(async () => { + getInput().focus() + }) + + expect(requestSuggestions).toHaveBeenCalledOnce() + }) + + it('searches suggestions past the previous 320 font cutoff', async () => { + const suggestions = [ + ...Array.from({ length: 350 }, (_value, index) => `System Font ${index}`), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('System Font 0') + return + } + + await act(async () => { + root.render() + }) + + const input = getInput() + + await act(async () => { + input.focus() + }) + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels()).not.toContain('Zed Mono') + + await typeIntoInput(input, 'Zed') + + expect(getOptionLabels()).toEqual(['Zed Mono']) + }) + + it('renders the selected late-list font even when the full list is capped', async () => { + const suggestions = [ + ...Array.from({ length: FONT_SUGGESTION_RENDER_LIMIT + 30 }, (_value, index) => { + return `System Font ${index}` + }), + 'Zed Mono' + ] + + function Harness(): ReactNode { + const [value, setValue] = useState('Zed Mono') + return + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT) + expect(getOptionLabels().at(-1)).toBe('Zed Mono') + }) }) diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a8b5bbdef05..146e0000f62 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -13,6 +13,7 @@ import { MAX_THEME_RESULTS } from './SettingsConstants' import { filterFontSuggestions, filterTerminalThemeOptions, + getRenderedFontSuggestions, isSettingsFormOptionQueryTooLarge } from './settings-form-option-filter' import { cn } from '@/lib/utils' @@ -286,6 +287,7 @@ type FontAutocompleteProps = { suggestions: string[] onChange: (value: string) => void placeholder?: string + onRequestSuggestions?: () => void /** Fires with whichever option the user is currently highlighting in the * dropdown (via mouse hover or keyboard arrow), or null when nothing is * highlighted / the dropdown is closed. Lets a consumer show a live @@ -588,6 +590,7 @@ export function FontAutocomplete({ suggestions, onChange, placeholder = 'SF Mono', + onRequestSuggestions, onPreviewFontFamily }: FontAutocompleteProps): React.JSX.Element { const [query, setQuery] = useState(value) @@ -619,8 +622,15 @@ export function FontAutocomplete({ } } + const requestSuggestions = useCallback((): void => { + onRequestSuggestions?.() + }, [onRequestSuggestions]) + const handleOpenChange = (nextOpen: boolean): void => { setOpen(nextOpen) + if (nextOpen) { + requestSuggestions() + } if (!nextOpen) { setIsFilteringQuery(false) } @@ -636,6 +646,10 @@ export function FontAutocomplete({ // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions + const renderedSuggestions = useMemo( + () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), + [visibleSuggestions, highlightedIndex] + ) // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter @@ -699,12 +713,14 @@ export function FontAutocomplete({ value={query} onChange={(e) => { const next = e.target.value + requestSuggestions() setQuery(next) setIsFilteringQuery(true) onChange(next) setOpen(true) }} onFocus={() => { + requestSuggestions() setIsFilteringQuery(false) setOpen(true) }} @@ -795,6 +811,7 @@ export function FontAutocomplete({ setIsFilteringQuery(false) } if (nextOpen) { + requestSuggestions() focusInput() } }} @@ -831,29 +848,29 @@ export function FontAutocomplete({ }} > 8 ? 'h-64' : undefined} + className={renderedSuggestions.length > 8 ? 'h-64' : undefined} style={popoverAvailableHeightStyle} viewportProps={{ style: popoverAvailableHeightStyle }} >
{visibleSuggestions.length > 0 ? ( - visibleSuggestions.map((font, index) => ( + renderedSuggestions.map(({ font, sourceIndex }) => ( - )} + placeholder={defaultCmd} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {cmdOverride && ( + + )} +
) } @@ -330,45 +339,47 @@ function AgentDefaultArgsInput({ } return ( -
- +
+ {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} - setArgsDraft(e.target.value)} - onBlur={commitArgs} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitArgs() - e.currentTarget.blur() - } - if (e.key === 'Escape') { - setArgsDraft(draftSeed) - e.currentTarget.blur() - } - }} - placeholder={ - defaultArgs || - translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') - } - spellCheck={false} - className="h-7 flex-1 font-mono text-xs" - /> - {argsOverride !== defaultArgs && ( - - )} + placeholder={ + defaultArgs || + translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments') + } + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {argsOverride !== defaultArgs && ( + + )} +
) } @@ -394,11 +405,11 @@ function AgentDefaultEnvInput({ } return ( -
+
+ + {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} +
- - {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')} - { @@ -476,7 +487,8 @@ function AgentRow({ onSetEnabled, onSaveOverride, onSaveArgs, - onSaveEnv + onSaveEnv, + sessionSourceHome }: AgentRowProps): React.JSX.Element { const envSummary = stringifyAgentDefaultEnvDraft(envOverride) const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv) @@ -494,15 +506,6 @@ function AgentRow({
{label} - {isDetected ? ( - - {translate('auto.components.settings.AgentsPane.c8794e622e', 'Detected')} - - ) : ( - - {translate('auto.components.settings.AgentsPane.df123171d1', 'Not installed')} - - )} {!isEnabled && ( {translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')} @@ -523,7 +526,7 @@ function AgentRow({
-
+
-
- {isDetected && ( - - )} -
-
)} -

+ {sessionSourceHome && ( +

+ +
+ )} +

{translate( 'auto.components.settings.AgentsPane.f9f127d664', 'Override the binary path or name, and edit the default launch arguments or environment for this agent.' @@ -902,6 +893,11 @@ export function AgentsPane({ onSaveOverride={(v) => saveOverride(agent.id, v)} onSaveArgs={(v) => saveAgentArgs(agent.id, v)} onSaveEnv={(v) => saveAgentEnv(agent.id, v)} + sessionSourceHome={ + agent.id === 'codex' + ? buildCodexSessionSourceHomeControl(settings, updateSettings) + : undefined + } /> ))}

diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx new file mode 100644 index 00000000000..731398c3d0b --- /dev/null +++ b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/types' +import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control' + +function settingsWith( + overrides: Partial> +): Pick { + return { + codexSessionSourceHome: overrides.codexSessionSourceHome, + localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' } + } +} + +describe('buildCodexSessionSourceHomeControl', () => { + it('reads and writes the host override on the host runtime', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ codexSessionSourceHome: { host: '/custom/codex' } }), + updateSettings + ) + + expect(control.runtimeLabel).toBe('~/.codex') + expect(control.value).toBe('/custom/codex') + + control.onSave('/new/codex') + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { host: '/new/codex' } + }) + }) + + it('scopes to the selected WSL distro', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }), + updateSettings + ) + + expect(control.value).toBe('/home/me/.codex') + control.onSave('/home/me/.config/codex') + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + }) + + it('reads a stored WSL value when the reported distro casing differs', () => { + // Stored as "Ubuntu"; the runtime reports "ubuntu". The launch-time resolver + // matches case-insensitively, so the UI must surface the active value too. + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + vi.fn() + ) + + expect(control.value).toBe('/home/me/.codex') + }) + + it('updates the existing WSL key in place instead of creating a duplicate casing', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + updateSettings + ) + + control.onSave('/home/me/.config/codex') + // No stray "ubuntu" key alongside "Ubuntu". + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + }) + + it('clears the matching WSL key regardless of casing when reset', () => { + const updateSettings = vi.fn() + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' } + }), + updateSettings + ) + + control.onSave('') + // Removing the only key leaves no dangling wsl object. + expect(updateSettings).toHaveBeenCalledWith({ + codexSessionSourceHome: { wsl: undefined } + }) + }) + + it('falls back to the host control when a WSL scope has no selected distro', () => { + const control = buildCodexSessionSourceHomeControl( + settingsWith({ + codexSessionSourceHome: { host: '/custom/codex' }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: null } + }), + vi.fn() + ) + + expect(control.runtimeLabel).toBe('~/.codex') + expect(control.value).toBe('/custom/codex') + }) +}) diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx new file mode 100644 index 00000000000..6a49a7b6042 --- /dev/null +++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react' +import { Info } from 'lucide-react' +import type { GlobalSettings } from '../../../../shared/types' +import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { translate } from '@/i18n/i18n' + +export type AgentSessionSourceHomeControl = { + runtimeLabel: string + value: string + onSave: (value: string) => void +} + +type UpdateSettings = (updates: Partial) => void | Promise + +/** + * Builds the Codex session-history source control scoped to the runtime the + * Agents pane is showing (host or the selected WSL distro), so it mirrors how + * detected agents are scoped. History-only: it never touches auth/config. + */ +// Why: the launch-time resolver matches distro keys case-insensitively, so the +// UI must resolve to the SAME stored key. Otherwise a casing mismatch would show +// an empty override for an active value and, on edit, create a duplicate key. +function findWslSourceHomeKey( + wsl: Record | undefined, + distro: string +): string | undefined { + const normalized = distro.trim().toLowerCase() + return Object.keys(wsl ?? {}).find((key) => key.trim().toLowerCase() === normalized) +} + +export function buildCodexSessionSourceHomeControl( + settings: Pick, + updateSettings: UpdateSettings +): AgentSessionSourceHomeControl { + const runtimeScope = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) + const sourceHome = settings.codexSessionSourceHome + // Why: a WSL scope with no selected distro can't target a per-distro history + // home, so fall back to the host control rather than a null distro key. + const wslDistro = runtimeScope.kind === 'wsl' ? runtimeScope.distro?.trim() : undefined + if (wslDistro) { + const existingKey = findWslSourceHomeKey(sourceHome?.wsl, wslDistro) + return { + runtimeLabel: `${wslDistro}: ~/.codex`, + value: (existingKey ? sourceHome?.wsl?.[existingKey] : undefined) ?? '', + // Save under the existing key when one matches (case-insensitively), so + // editing updates it in place instead of adding a differently-cased key. + onSave: (value: string) => + saveCodexSessionSourceHome(settings, updateSettings, { + runtime: 'wsl', + distro: existingKey ?? wslDistro, + value + }) + } + } + return { + runtimeLabel: '~/.codex', + value: sourceHome?.host ?? '', + onSave: (value: string) => + saveCodexSessionSourceHome(settings, updateSettings, { runtime: 'host', value }) + } +} + +function saveCodexSessionSourceHome( + settings: Pick, + updateSettings: UpdateSettings, + args: { runtime: 'host'; value: string } | { runtime: 'wsl'; distro: string; value: string } +): void { + const current = settings.codexSessionSourceHome ?? {} + const trimmed = args.value.trim() + if (args.runtime === 'host') { + updateSettings({ codexSessionSourceHome: { ...current, host: trimmed || undefined } }) + return + } + const nextWsl = { ...current.wsl } + // Why: reuse an existing case-insensitive match so we never leave a stale + // duplicate key behind when the caller passes a differently-cased distro. + const targetKey = findWslSourceHomeKey(nextWsl, args.distro) ?? args.distro + if (trimmed) { + nextWsl[targetKey] = trimmed + } else { + delete nextWsl[targetKey] + } + updateSettings({ + codexSessionSourceHome: { + ...current, + wsl: Object.keys(nextWsl).length > 0 ? nextWsl : undefined + } + }) +} + +export function AgentSessionSourceHomeInput({ + runtimeLabel, + value, + onSave +}: AgentSessionSourceHomeControl): React.JSX.Element { + const [draft, setDraft] = useState(value) + + const commit = (): void => { + onSave(draft.trim()) + } + + return ( +
+ + {translate( + 'auto.components.settings.AgentsPane.codexSessionSource', + 'Codex home to import from' + )} + + + + + + {translate( + 'auto.components.settings.AgentsPane.codexSessionSourceTooltip', + 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.' + )} + + + +
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit() + e.currentTarget.blur() + } + if (e.key === 'Escape') { + setDraft(value) + e.currentTarget.blur() + } + }} + placeholder={runtimeLabel} + spellCheck={false} + className="h-7 flex-1 font-mono text-xs" + /> + {value.trim() && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2d4efa1e146..7ea7146ec6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4758,7 +4758,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "Environment text is too large to parse safely." + "3f1bdf3cb4": "Environment text is too large to parse safely.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Next icon", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 66a2b10f23b..cc51dd41b99 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4758,7 +4758,10 @@ "agentPermissionsDescription": "Elige si Orca lanza agents con menos prompts de permisos o con checks manuales.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", - "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura." + "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "Icono siguiente", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2b76dba34a1..a70953da324 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手動", - "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。" + "3f1bdf3cb4": "環境テキストが大きすぎるため安全に解析できません。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "次へのアイコン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 646b74a42f7..511f3b1e3c8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "Orca가 agents를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "수동", - "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다." + "3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다.", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "다음 아이콘", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 66de9505f78..d46dfc98f40 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4743,7 +4743,10 @@ "agentPermissionsDescription": "选择 Orca 是以更少的权限提示启动智能体,还是手动检查。", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "手动", - "3f1bdf3cb4": "环境文本过长,无法安全解析。" + "3f1bdf3cb4": "环境文本过长,无法安全解析。", + "codexSessionSource": "Codex home to import from", + "codexSessionSourceInfo": "About importing Codex history", + "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex." }, "AppIconSelector": { "d5a112dc9b": "下一个图标", diff --git a/src/shared/types.ts b/src/shared/types.ts index 9c074ad2b43..92715f7861a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2784,6 +2784,16 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** Why: Orca bridges Codex session history from the user's real Codex home into + * its managed home so /resume finds it, but defaults to ~/.codex. Users who run + * Codex with a custom CODEX_HOME can point history discovery at that folder here. + * History-only: this does not change which account/config/hooks Orca uses. */ + codexSessionSourceHome?: { + /** Absolute host path; empty/undefined falls back to ~/.codex. */ + host?: string + /** Per-WSL-distro absolute Linux path; missing distro falls back to /.codex. */ + wsl?: Record + } /** Per-agent default CLI arguments appended after the binary/path and before prompts. */ agentDefaultArgs?: Partial> /** Per-agent launch environment defaults used when yolo mode is exposed as env. */ From 46a67cb2eb1d71ab4dfd3a94224cb533793e44d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:02:59 -0700 Subject: [PATCH 39/46] perf(source-control): cache PR conflict-summary derivation and throttle base fetch (#7606) Co-authored-by: Orca --- src/main/github/client.test.ts | 16 +- src/main/github/conflict-summary-cache.ts | 150 ++++++++++ src/main/github/conflict-summary.test.ts | 331 ++++++++++++++++++++++ src/main/github/conflict-summary.ts | 126 ++++++-- 4 files changed, 597 insertions(+), 26 deletions(-) create mode 100644 src/main/github/conflict-summary-cache.ts create mode 100644 src/main/github/conflict-summary.test.ts diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2138ba0aad0..5363cb56c60 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -122,7 +122,7 @@ import { _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' -import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary' +import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' describe('checkOrcaStarred', () => { @@ -194,7 +194,7 @@ describe('getPRForBranch', () => { _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetTrackedUpstreamBranchCacheForTests() - __resetPRConflictSummaryGitCapabilityCacheForTests() + __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() }) @@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => { baseRefOid: 'base-oid', headRefOid: 'head-oid' } + // Why a second head OID: identical inputs now hit the summary result + // cache outright; a pushed head re-derives and must still skip the + // unsupported --merge-base retry via the capability cache. + const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } + const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) @@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetPRConflictSummaryCachesForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts new file mode 100644 index 00000000000..99a2715ce6d --- /dev/null +++ b/src/main/github/conflict-summary-cache.ts @@ -0,0 +1,150 @@ +import type { PRConflictSummary } from '../../shared/types' + +// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR +// (10s mergeability-pending, 2.5s manual-pending) previously each ran a +// network fetch; one fetch per base branch per minute matches the 60s minimum +// background refresh cadence, so the tracked base tip is never staler than the +// PR data around it. Manual refresh intentionally shares the window: GitHub's +// own mergeability recompute is async too, and the card self-corrects within +// a minute. +export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000 + +// Why bounded: the main process is long-lived; cap the maps so repos and PRs +// that stop refreshing can't accumulate entries forever. +const BASE_OID_CACHE_MAX = 64 +const SUMMARY_CACHE_MAX = 128 + +export type FreshBaseTipResolution = + | { kind: 'resolved'; oid: string } + | { kind: 'fallback-unresolved' } + +type CachedBaseTipResolution = { + oid: string | null + resolvedAt: number +} + +type CachedSummary = { + value: PRConflictSummary | undefined + // Why: a successful summary is a pure function of two immutable commit OIDs + // and never goes stale; a failed derivation depends on which objects exist + // locally, which the next fetch window can change, so it carries an expiry. + staleAt: number | null +} + +const baseOidCache = new Map() +const summaryCache = new Map() +const inFlightBaseOidResolves = new Map>() +const inFlightSummaryDerivations = new Map>() + +// Why: WSL distros have their own git binary, filesystem view, and remote +// access, so cached state must never leak across the host/distro boundary. +export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string { + return wslDistro ? `wsl:${wslDistro}` : 'local:host' +} + +// Why JSON: repo paths and git ref names may contain any printable joiner +// character (git allows `|` in branch names), so a delimiter-joined key could +// alias distinct identities onto one cache entry. +export function buildConflictSummaryCacheKey(...parts: string[]): string { + return JSON.stringify(parts) +} + +export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null { + const entry = baseOidCache.get(baseKey) + if (!entry) { + return null + } + if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) { + baseOidCache.delete(baseKey) + return null + } + return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' } +} + +export function storeResolvedBaseTip(baseKey: string, oid: string): void { + setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX) +} + +export function rememberUnresolvedBaseTip(baseKey: string): void { + setBoundedMapEntry( + baseOidCache, + baseKey, + { oid: null, resolvedAt: Date.now() }, + BASE_OID_CACHE_MAX + ) +} + +export function readCachedSummary(summaryKey: string): CachedSummary | null { + const entry = summaryCache.get(summaryKey) + if (!entry) { + return null + } + if (entry.staleAt !== null && Date.now() >= entry.staleAt) { + summaryCache.delete(summaryKey) + return null + } + return entry +} + +export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void { + setBoundedMapEntry( + summaryCache, + summaryKey, + { + value, + staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null + }, + SUMMARY_CACHE_MAX + ) +} + +export function dedupeBaseOidResolve( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightBaseOidResolves, key, factory) +} + +export function dedupeSummaryDerivation( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightSummaryDerivations, key, factory) +} + +function dedupeInFlight( + map: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = map.get(key) + if (existing) { + return existing + } + const promise = factory().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + +export function __resetPRConflictSummaryDerivationCachesForTests(): void { + baseOidCache.clear() + summaryCache.clear() + inFlightBaseOidResolves.clear() + inFlightSummaryDerivations.clear() +} diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts new file mode 100644 index 00000000000..a9b5e9aee9a --- /dev/null +++ b/src/main/github/conflict-summary.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why these tests exist: the conflict-summary derivation used to re-run a +// network `git fetch` plus a four-subprocess chain on every PR refresh tick +// (measured ~490 full derivations in 2.1h on one machine). They pin the +// regression contract by counting subprocess spawns: unchanged inputs must +// cost zero subprocesses, and the base fetch must run at most once per +// throttle window. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache' +import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary' + +type GitResult = { stdout: string } +type GitHandler = (argv: string[]) => Promise + +const defaultHandlers: Record = { + fetch: async () => ({ stdout: '' }), + 'rev-parse': async () => ({ stdout: 'base-tip-1\n' }), + 'merge-base': async () => ({ stdout: 'merge-base-1\n' }), + 'rev-list': async () => ({ stdout: '3\n' }), + 'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' }) +} + +function mockGitDispatch(overrides: Record = {}): void { + gitExecFileAsyncMock.mockImplementation((argv: string[]) => { + const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]] + if (!handler) { + return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`)) + } + return handler(argv) + }) +} + +function spawnCount(command?: string): number { + const calls = gitExecFileAsyncMock.mock.calls + if (!command) { + return calls.length + } + return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length +} + +const expectedSummary = { + baseRef: 'main', + baseCommit: 'base-ti', + commitsBehind: 3, + files: ['src/conflict.ts'] +} + +function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) { + return getPRConflictSummary( + '/repo-root', + 'main', + 'github-base-oid', + headRefOid, + wslDistro ? { wslDistro } : {} + ) +} + +describe('getPRConflictSummary caching', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_750_000_000_000) + gitExecFileAsyncMock.mockReset() + __resetPRConflictSummaryCachesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => { + mockGitDispatch() + + const first = await deriveSummary() + expect(first).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + + const second = await deriveSummary() + const third = await deriveSummary() + expect(second).toEqual(expectedSummary) + expect(third).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => { + mockGitDispatch() + + await deriveSummary('head-oid-1') + const afterPush = await deriveSummary('head-oid-2') + + expect(afterPush).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => { + mockGitDispatch() + + await deriveSummary() + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + const afterExpiry = await deriveSummary() + + expect(afterExpiry).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('rev-parse')).toBe(2) + // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits. + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('re-derives after the throttle window when the base tip moved', async () => { + mockGitDispatch() + await deriveSummary() + + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch({ + 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }), + 'rev-list': async () => ({ stdout: '5\n' }) + }) + const afterBaseMove = await deriveSummary() + + expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 }) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const firstCall = deriveSummary() + const secondCall = deriveSummary() + releaseFetch?.({ stdout: '' }) + const [first, second] = await Promise.all([firstCall, secondCall]) + + expect(first).toEqual(expectedSummary) + expect(second).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('dedupes overlapping derivations after resolving the same live base tip', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const backgroundRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'older-github-base-oid', + 'head-oid-1', + {} + ) + const manualRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'newer-github-base-oid', + 'head-oid-1', + {} + ) + releaseFetch?.({ stdout: '' }) + const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh]) + + expect(backgroundSummary).toEqual(expectedSummary) + expect(manualSummary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('shares one base fetch across concurrent PRs on the same base branch', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const prA = deriveSummary('head-oid-a') + const prB = deriveSummary('head-oid-b') + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual(expectedSummary) + expect(summaryB).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }), + 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')), + 'rev-list': async (argv) => ({ + stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n' + }), + 'merge-tree': async (argv) => ({ + stdout: argv.includes('bbbb2222-base') + ? 'tree-oid\u0000src/b.ts\u0000' + : 'tree-oid\u0000src/a.ts\u0000' + }) + }) + + const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {}) + const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {}) + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual({ + baseRef: 'main', + baseCommit: 'aaaa111', + commitsBehind: 4, + files: ['src/a.ts'] + }) + expect(summaryB).toEqual({ + baseRef: 'main', + baseCommit: 'bbbb222', + commitsBehind: 7, + files: ['src/b.ts'] + }) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('falls back to the local remote-tracking ref when fetch fails', async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + + // Why: a failed fetch attempt must also be throttled — offline machines + // were the worst case, paying the 10s fetch timeout on every tick. + const repeat = await deriveSummary() + expect(repeat).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + }) + + it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')), + 'rev-parse': () => Promise.reject(new Error('unknown revision')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' }) + expect( + gitExecFileAsyncMock.mock.calls.some( + ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid') + ) + ).toBe(true) + }) + + it('keeps WSL-distro derivations isolated from host derivations', async () => { + mockGitDispatch() + + await deriveSummary() + await deriveSummary('head-oid-1', 'Ubuntu') + + expect(spawnCount('fetch')).toBe(2) + expect( + gitExecFileAsyncMock.mock.calls.filter( + ([argv, options]) => + argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu' + ) + ).toHaveLength(1) + }) + + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { + mockGitDispatch() + + // Why: these two calls alias under naive pipe-joined keys — the segment + // boundary shifts between repoPath and baseRefName. + await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) + await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) + + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('caches failed derivations only until the throttle window expires', async () => { + mockGitDispatch({ + 'merge-base': () => Promise.reject(new Error('bad object')) + }) + + await expect(deriveSummary()).resolves.toBeUndefined() + const failureSpawns = spawnCount() + + // Within the window the failure is negative-cached: zero subprocesses. + await expect(deriveSummary()).resolves.toBeUndefined() + expect(spawnCount()).toBe(failureSpawns) + + // After expiry the derivation retries (a fetch may have brought objects in). + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch() + await expect(deriveSummary()).resolves.toEqual(expectedSummary) + expect(spawnCount('merge-base')).toBe(2) + }) +}) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce0c4b361b7..9644555048a 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,5 +1,17 @@ import type { PRConflictSummary } from '../../shared/types' import { gitExecFileAsync } from '../git/runner' +import { + __resetPRConflictSummaryDerivationCachesForTests, + buildConflictSummaryCacheKey, + dedupeBaseOidResolve, + dedupeSummaryDerivation, + getConflictSummaryGitRuntimeKey, + readCachedSummary, + readFreshBaseTipResolution, + rememberUnresolvedBaseTip, + storeResolvedBaseTip, + storeCachedSummary +} from './conflict-summary-cache' type LocalGitExecOptions = { wslDistro?: string @@ -7,8 +19,9 @@ type LocalGitExecOptions = { const mergeTreeMergeBaseUnsupportedRuntimes = new Set() -export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void { +export function __resetPRConflictSummaryCachesForTests(): void { mergeTreeMergeBaseUnsupportedRuntimes.clear() + __resetPRConflictSummaryDerivationCachesForTests() } export async function getPRConflictSummary( @@ -18,45 +31,122 @@ export async function getPRConflictSummary( headRefOid: string, localGitOptions: LocalGitExecOptions = {} ): Promise { - try { - // Why: the renderer only needs a read-only merge-conflict snapshot. We - // derive it from local git state so the PR card can show GitHub-style - // detail without spending additional gh API calls on every refresh. We use - // GitHub's head OID directly because the registered repo path may not have - // a matching local branch name for the PR head. For the base side, prefer a - // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, - // which compares against the latest base branch tip rather than the PR's - // older pinned baseRefOid snapshot. - const latestBaseOid = await resolveLatestBaseOid( + // Why: the renderer only needs a read-only merge-conflict snapshot. We + // derive it from local git state so the PR card can show GitHub-style + // detail without spending additional gh API calls on every refresh. We use + // GitHub's head OID directly because the registered repo path may not have + // a matching local branch name for the PR head. For the base side, prefer a + // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, + // which compares against the latest base branch tip rather than the PR's + // older pinned baseRefOid snapshot. + const latestBaseOid = await resolveLatestBaseOidThrottled( + repoPath, + baseRefName, + baseRefOid, + localGitOptions + ) + // Why: the summary is a pure function of the two commit OIDs, so a key hit + // can skip the whole merge-base/rev-list/merge-tree subprocess chain. + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const summaryKey = buildConflictSummaryCacheKey( + runtimeKey, + repoPath, + baseRefName, + headRefOid, + latestBaseOid + ) + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + // Why: different GitHub reads can report different pinned baseRefOid values + // while still resolving to the same live base tip; dedupe the expensive + // local derivation on the actual summary identity. + return dedupeSummaryDerivation(summaryKey, () => + derivePRConflictSummary( repoPath, baseRefName, - baseRefOid, + headRefOid, + latestBaseOid, + summaryKey, localGitOptions ) + ) +} + +async function derivePRConflictSummary( + repoPath: string, + baseRefName: string, + headRefOid: string, + latestBaseOid: string, + summaryKey: string, + localGitOptions: LocalGitExecOptions +): Promise { + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + try { const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions) const [commitsBehind, files] = await Promise.all([ countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions), loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions) ]) - return { + const summary = { baseRef: baseRefName, baseCommit: latestBaseOid.slice(0, 7), commitsBehind, files, ...(files.length === 0 ? { localMergeState: 'clean' as const } : {}) } + storeCachedSummary(summaryKey, summary) + return summary } catch { + storeCachedSummary(summaryKey, undefined) return undefined } } -async function resolveLatestBaseOid( +async function resolveLatestBaseOidThrottled( repoPath: string, baseRefName: string, fallbackBaseOid: string, localGitOptions: LocalGitExecOptions ): Promise { + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName) + const cachedResolution = readFreshBaseTipResolution(baseKey) + if (cachedResolution) { + return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid + } + return dedupeBaseOidResolve(baseKey, async () => { + // Why re-check inside the dedupe slot: a sibling caller may have finished + // resolving between our cache read and this factory starting. + const freshResolution = readFreshBaseTipResolution(baseKey) + if (freshResolution) { + return freshResolution + } + const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions) + if (oid) { + storeResolvedBaseTip(baseKey, oid) + return { kind: 'resolved', oid } + } + // Why cache the unresolved probe, not the caller fallback: the fetch + // attempt is branch-wide expensive work, but GitHub's baseRefOid is + // PR-specific and must not leak to sibling PRs on the same base branch. + rememberUnresolvedBaseTip(baseKey) + return { kind: 'fallback-unresolved' } + }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid)) +} + +async function resolveLatestBaseOid( + repoPath: string, + baseRefName: string, + localGitOptions: LocalGitExecOptions +): Promise { const remoteName = 'origin' try { @@ -88,7 +178,7 @@ async function resolveLatestBaseOid( } } - return fallbackBaseOid + return null } async function resolveMergeBase( @@ -123,7 +213,7 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilityKey = getMergeTreeCapabilityKey(localGitOptions) + const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) const modernArgs = [ 'merge-tree', '--write-tree', @@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string { - return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host' -} - function parseMergeTreeNameOnlyOutput(stdout: string): string[] { const entries = stdout.split('\0').filter(Boolean) if (entries.length === 0) { From 544bca5202fc43de3ad94b3c4ae6b94b063e85f6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:03:39 -0700 Subject: [PATCH 40/46] fix: terminal IME candidate selection and text commit on Linux (#7634) * fix terminal IME candidate selection and text commit on Linux Sogou Pinyin and fcitx on Linux failed in Orca's terminal because bare 229 keydowns were swallowed, and empty composition updates prematurely deactivated tracking. This led to dropped Chinese text or leaked Space/digit candidate-selection keys reaching the PTY. - Allow bare 229 keydowns to bypass suppression on Linux so xterm can diff and commit text. - Prevent empty compositionupdate events from prematurely deactivating the composition tracker. - Suppress and preventDefault candidate-selection keys (Space and digits) during active composition and a brief post-composition window. - Add comprehensive unit tests and an Electron CDP-driven E2E repro. * fix: register IME gate command as direct spec-file invocation The reliability-gate checker rejects --grep title selectors and requires every evidenceRun command to match a gate command. Drop the --grep from the e2e gate command and its evidence run, and remove the stale 3-file evidence run superseded by the full 7-file run. Co-authored-by: Orca * Guard overlapping and post-composition Linux IME candidate keys - Track pending candidate key releases in a Map instead of a single slot to support overlapping selector key events without stranding. - Apply the candidate selection guard to post-composition key releases that arrive after compositionend, preventing digits/Space from leaking into the PTY. - Restrict the Linux/Sogou candidate selection guard to Linux to prevent interference on macOS and Windows. - Exclude Shift+Space from candidate selection key checks. * Guard held-key IME candidate repeats and scope policy to desktop Linux - Keep auto-repeat keydowns for a candidate key suppressed past the 250ms guard window until its corresponding keyup event is received. - Clear stale pending releases on fresh non-repeat keydowns to avoid guarding the wrong key events. - Exclude Android and ChromeOS user agents from desktop Linux-specific IME candidate key suppression behaviors. - Ensure the composition tracker is activated unconditionally on compositionupdate events. * Clean up IME reference and extract shared test event fixture - Remove the obsolete Linux Sogou Pinyin IME reference document. - Extract the fully-defaulted XtermBypassEvent helper into a shared fixture file to keep the policy test suites in sync. - Add a test verifying that Shift+Space (fcitx full-/half-width toggle) is not suppressed as an IME candidate key. --------- Co-authored-by: Orca --- config/reliability-gates.jsonc | 84 ++++-- ...al-ime-candidate-key-release-guard.test.ts | 171 ++++++++++++ ...erminal-ime-candidate-key-release-guard.ts | 94 +++++++ .../terminal-ime-composition-tracker.test.ts | 167 ++++++++++++ .../terminal-ime-composition-tracker.ts | 75 +++++- .../use-terminal-pane-lifecycle.ts | 52 +++- .../xterm-bypass-event-fixture.ts | 17 ++ .../xterm-bypass-policy-non-mac.test.ts | 255 +++++++++++++++--- .../terminal-pane/xterm-bypass-policy.test.ts | 50 ++-- .../terminal-pane/xterm-bypass-policy.ts | 60 ++++- .../e2e/chinese-ime-chat-input-repro.spec.ts | 194 +++++++++++++ 11 files changed, 1132 insertions(+), 87 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bdabd2a7b79..1975f41c7f9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-03", + "updatedAt": "2026-07-07", "policy": { "maturityLevels": [ "experimental", @@ -1273,7 +1273,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1281,14 +1281,20 @@ "https://github.com/stablyai/orca/pull/6999" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ { @@ -1309,34 +1315,80 @@ "assertions": [ "paste/runtime forwarding avoids duplicate terminal payloads" ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "assertions": [ + "empty Sogou-style compositionupdate keeps composition active", + "stale composition state expires so editing keys cannot stay suppressed indefinitely", + "post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "assertions": [ + "suppressed candidate keydown arms only the matching keypress/keyup release guard", + "held-key repeat keydowns stay guarded until their keyup, even past expiry", + "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "assertions": [ + "standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed", + "candidate Space/digit selectors are suppressed only while candidate guards are active" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "assertions": [ + "macOS standalone Process key behavior and composition-owned key suppression stay intact" + ] + }, + { + "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "assertions": [ + "Sogou-style Space candidate selection submits only the committed Chinese character", + "Sogou-style digit candidate selection submits only the committed Chinese phrase", + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "result": "passed", - "durationSeconds": 0.9, - "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 0.7, + "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 72.0, + "summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME." } ], "runtimeBudget": { - "p95Seconds": 30, - "scope": "renderer unit plus platform soak" + "p95Seconds": 90, + "scope": "renderer unit plus focused Electron/CDP IME repro" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion." + "evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { - "required": false, - "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work." + "required": true, + "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", @@ -1344,7 +1396,7 @@ "Pair with Windows ConPTY keyboard reset for Windows standard-key behavior." ], "knownGaps": [ - "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.", + "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts new file mode 100644 index 00000000000..228ff9ddca6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + isTerminalImeCandidateSelectionKeyEvent, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' +import { event } from './xterm-bypass-event-fixture' + +describe('terminal IME candidate key release guard', () => { + it('recognizes only unmodified Space and digit candidate selectors', () => { + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: '2' }))).toBe(true) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: 'a' }))).toBe(false) + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', ctrlKey: true }))).toBe(false) + // Shift+Space is fcitx's full-/half-width toggle, not a candidate selector. + expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false) + }) + + it('arms a pending release guard from a suppressed candidate keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect(releases.get('2')).toBe(10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + }) + + it('does not arm from keyup, non-candidate, or Shift-modified keys', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: 'a' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ', shiftKey: true }), 10) + expect(releases.size).toBe(0) + }) + + it('guards the matching keypress and keyup after insertText clears the tracker', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: ' ' }), + releases, + 20 + ) + ).toBe(true) + }) + + it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect(shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20)).toBe( + false + ) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2', ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keypress', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('guards held-key repeat keydowns while the release is pending, even past expiry', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + // Linux auto-repeat delay (~500ms) outlives the 250ms guard window; the + // held selector's repeats must stay suppressed until its keyup. + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 500 + ) + ).toBe(true) + }) + + it('does not guard repeat keydowns without a pending release for that key', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '3', repeat: true }), + releases, + 20 + ) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ key: '2', repeat: true, ctrlKey: true }), + releases, + 20 + ) + ).toBe(false) + }) + + it('clears a stale pending release on a fresh non-repeat keydown but not on repeats', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2', repeat: true })) + expect(releases.has('2')).toBe(true) + // A new physical press means the prior keyup was missed (focus change + // mid-hold); its stale entry must not guard the new press's repeats. + clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('guards a pending matching keyup even if modifier state changed after keydown', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2', shiftKey: true }), + releases, + 10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1 + ) + ).toBe(true) + }) + + it('clears on the matching keyup', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keypress', key: '2' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.has('2')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('2')).toBe(false) + }) + + it('tracks overlapping candidate keys independently so each keyup clears its own guard', () => { + const releases = createTerminalImePendingCandidateKeyReleases() + // Second candidate keydown arrives before the first key's keyup. + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) + armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '2' }), releases, 20) + ).toBe(true) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + ).toBe(true) + // The first key's keyup no longer strands the second key's pending release. + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) + expect(releases.has('3')).toBe(true) + clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' })) + expect(releases.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts new file mode 100644 index 00000000000..210d8de22d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.ts @@ -0,0 +1,94 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' +import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker' + +// Why: candidate keys can overlap (a second selector keydown before the first +// keyup), so pending releases are tracked per key rather than in one slot; +// otherwise an overwritten key's keyup can no longer clear its own guard. +export type TerminalImePendingCandidateKeyReleases = Map + +// Why: Sogou/fcitx can deliver candidate-selection keys as plain key events +// (#7543: digit selection inserts only the digit). While the IME owns them, +// they must not reach xterm's encoders or Chromium's default text insertion. +const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([ + ' ', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9' +]) + +function isTerminalImeCandidateSelectionKey(key: string): boolean { + return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key) +} + +export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent): boolean { + // Modified chords are never candidate selectors: Ctrl/Meta/Alt are IME + // toggles, and Shift+Space is fcitx's full-/half-width width toggle. + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) { + return false + } + return isTerminalImeCandidateSelectionKey(event.key) +} + +export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases { + return new Map() +} + +export function armTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent, + now: number +): void { + if (event.type !== 'keydown' || !isTerminalImeCandidateSelectionKeyEvent(event)) { + return + } + releases.set(event.key, now + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) +} + +export function shouldApplyTerminalImePendingCandidateKeyRelease( + event: XtermBypassEvent, + releases: TerminalImePendingCandidateKeyReleases, + now: number +): boolean { + if (event.type === 'keydown') { + // Why: key auto-repeat outlives the 250ms window (Linux repeat delay is + // ~500ms), so a held selector's repeats stay owned by its pending release; + // a fresh press means the prior keyup was missed and the entry is stale. + return ( + event.repeat === true && + isTerminalImeCandidateSelectionKeyEvent(event) && + releases.has(event.key) + ) + } + if (event.type === 'keyup') { + // Why: keyup modifier flags can reflect keys pressed after the original + // selector keydown, so release suppression matches only the pending key. + return isTerminalImeCandidateSelectionKey(event.key) && releases.has(event.key) + } + if (!isTerminalImeCandidateSelectionKeyEvent(event)) { + return false + } + const expiresAt = releases.get(event.key) + if (expiresAt === undefined) { + return false + } + return now <= expiresAt +} + +export function clearTerminalImePendingCandidateKeyRelease( + releases: TerminalImePendingCandidateKeyReleases, + event: XtermBypassEvent +): void { + // Why: a non-repeat keydown is a new physical press, so any surviving entry + // for that key lost its keyup (focus change mid-hold) and must not guard the + // new press's repeats. Callers clear before arming. + if (event.type === 'keyup' || (event.type === 'keydown' && event.repeat !== true)) { + releases.delete(event.key) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts new file mode 100644 index 00000000000..1e363adcec4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + installTerminalImeCompositionTracker, + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS, + TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS, + type TerminalImeCompositionTracker +} from './terminal-ime-composition-tracker' + +type TrackerHarness = { + tracker: TerminalImeCompositionTracker + element: HTMLElement + advance: (ms: number) => void + composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void + input: (inputType: string) => void + blur: () => void +} + +function installTracker(): TrackerHarness { + let now = 0 + const element = document.createElement('div') + const tracker = installTerminalImeCompositionTracker(element, { now: () => now }) + return { + tracker, + element, + advance: (ms) => { + now += ms + }, + composition: (type, data) => { + const event = new CompositionEvent(type, { bubbles: true }) + Object.defineProperty(event, 'data', { value: data }) + element.dispatchEvent(event) + }, + input: (inputType) => { + element.dispatchEvent(new InputEvent('input', { inputType, bubbles: true })) + }, + blur: () => { + element.dispatchEvent(new Event('blur', { bubbles: true })) + } + } +} + +describe('installTerminalImeCompositionTracker', () => { + it('activates on compositionstart', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('keeps composition active through Sogou-style empty compositionupdate', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', 'ni') + harness.composition('compositionupdate', '') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on compositionend', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('still clears on non-composition input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertText') + expect(harness.tracker.isActive()).toBe(false) + }) + + it('does not clear on insertCompositionText input', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.input('insertCompositionText') + expect(harness.tracker.isActive()).toBe(true) + }) + + it('clears on blur', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.blur() + expect(harness.tracker.isActive()).toBe(false) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + describe('candidate key guard', () => { + it('is active while a composition is live', () => { + const harness = installTracker() + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + harness.composition('compositionstart', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('stays active through empty compositionupdate events', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('expires after prolonged composition-event silence so stale IME state cannot keep terminal keys dead', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + expect(harness.tracker.isActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + expect(harness.tracker.isActive()).toBe(false) + }) + + it('refreshes the expiry window on composition activity', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + harness.composition('compositionupdate', '') + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + }) + + it('does not arm the post-composition window without Sogou-style empty updates', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('absorbs the Sogou-style committing key after compositionend, then expires', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '你') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true) + harness.advance(1) + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + + it('drops the post-composition window once ordinary typing resumes', () => { + const harness = installTracker() + harness.composition('compositionstart', '') + harness.composition('compositionupdate', '') + harness.composition('compositionend', '你') + harness.input('insertText') + expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false) + }) + }) + + it('handles a missing terminal element', () => { + const tracker = installTerminalImeCompositionTracker(null) + expect(tracker.isActive()).toBe(false) + expect(tracker.isCandidateKeyGuardActive()).toBe(false) + expect(() => tracker.dispose()).not.toThrow() + }) + + it('stops tracking after dispose', () => { + const harness = installTracker() + harness.tracker.dispose() + harness.composition('compositionstart', '') + expect(harness.tracker.isActive()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts index 0e3c9e4bbfb..73f6d83a0b0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -2,47 +2,112 @@ import type { IDisposable } from '@xterm/xterm' export type TerminalImeCompositionTracker = IDisposable & { isActive: () => boolean + /** True while candidate-selection keys (Space/digits) should be treated as + * IME-owned: during a live composition, and briefly after compositionend to + * absorb the committing key's trailing press/release. */ + isCandidateKeyGuardActive: () => boolean } +// Why: suppressed candidate keys are preventDefault-ed and fire no input +// event, so a stale tracker (missed compositionend) has no natural unstick +// path. Expire the guard so Space/digits cannot stay dead indefinitely. +export const TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS = 10_000 +// Why: Sogou/fcitx can deliver the committing Space/digit as plain keydown and +// keyup after compositionend; a narrow window absorbs those trailing events +// without making the keys globally unavailable after IME use. +export const TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS = 250 + export function installTerminalImeCompositionTracker( - terminalElement: HTMLElement | null | undefined + terminalElement: HTMLElement | null | undefined, + options?: { now?: () => number } ): TerminalImeCompositionTracker { + const now = options?.now ?? ((): number => Date.now()) let active = false + let lastCompositionEventAt: number | null = null + let compositionEndedAt: number | null = null + let sawEmptyCompositionUpdate = false + + const isActiveAt = (at: number): boolean => + active && + (lastCompositionEventAt === null || + at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS) + + const isCandidateKeyGuardActive = (): boolean => { + const at = now() + if (isActiveAt(at)) { + return true + } + return ( + compositionEndedAt !== null && + at - compositionEndedAt <= TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + ) + } + if (!terminalElement) { return { isActive: () => active, + isCandidateKeyGuardActive, dispose: () => undefined } } const markActive = (): void => { active = true + lastCompositionEventAt = now() + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const updateComposition = (event: Event): void => { - active = !(event instanceof CompositionEvent) || event.data !== '' + lastCompositionEventAt = now() + // Why: Sogou/fcitx can emit empty compositionupdate data while its + // candidate popup is still open — empty data must not deactivate. + // compositionend, non-composition input, and blur own deactivation. + if (!(event instanceof CompositionEvent)) { + return + } + if (event.data === '') { + sawEmptyCompositionUpdate = true + return + } + active = true + } + const handleCompositionEnd = (): void => { + active = false + // Why: only Sogou/fcitx-style empty updates prove a trailing plain + // Space/digit is likely IME-owned; broad post-end guards drop real typing. + compositionEndedAt = sawEmptyCompositionUpdate ? now() : null + sawEmptyCompositionUpdate = false } const handleInput = (event: Event): void => { if (event instanceof InputEvent && event.inputType === 'insertCompositionText') { return } active = false + // Why: real non-composition input means ordinary typing resumed; keeping + // the post-end window would swallow a legitimate Space/digit. + compositionEndedAt = null + sawEmptyCompositionUpdate = false } const markInactive = (): void => { active = false + lastCompositionEventAt = null + compositionEndedAt = null + sawEmptyCompositionUpdate = false } terminalElement.addEventListener('compositionstart', markActive, true) terminalElement.addEventListener('compositionupdate', updateComposition, true) - terminalElement.addEventListener('compositionend', markInactive, true) + terminalElement.addEventListener('compositionend', handleCompositionEnd, true) terminalElement.addEventListener('input', handleInput, true) terminalElement.addEventListener('blur', markInactive, true) return { - isActive: () => active, + isActive: () => isActiveAt(now()), + isCandidateKeyGuardActive, dispose: () => { terminalElement.removeEventListener('compositionstart', markActive, true) terminalElement.removeEventListener('compositionupdate', updateComposition, true) - terminalElement.removeEventListener('compositionend', markInactive, true) + terminalElement.removeEventListener('compositionend', handleCompositionEnd, true) terminalElement.removeEventListener('input', handleInput, true) terminalElement.removeEventListener('blur', markInactive, true) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 4baa9944ee2..e342778eed5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -64,6 +64,12 @@ import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' +import { + armTerminalImePendingCandidateKeyRelease, + clearTerminalImePendingCandidateKeyRelease, + createTerminalImePendingCandidateKeyReleases, + shouldApplyTerminalImePendingCandidateKeyRelease +} from './terminal-ime-candidate-key-release-guard' import { DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES, getMacNativeTextInputSourceTracker @@ -72,6 +78,7 @@ import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-tex import { shouldBypassXtermKeyboardEvent, shouldHandleTerminalInterruptKeyboardEvent, + shouldPreventDefaultTerminalImeCandidateKey, shouldSuppressTerminalImeKeyboardEvent, shouldSuppressTerminalInterruptKeyup, shouldSuppressTerminalModifierKeyboardEvent, @@ -842,7 +849,16 @@ export function useTerminalPaneLifecycle({ // encoder runs, letting the browser and Electron paths fire normally. // See xterm-bypass-policy.ts for the rule derivation. let pendingTerminalInterruptKeyup = false + const pendingTerminalImeCandidateKeyReleases = + createTerminalImePendingCandidateKeyReleases() const isMac = navigator.userAgent.includes('Mac') + // Why: Android/ChromeOS UAs also contain "Linux"; keep the Sogou/fcitx + // candidate-key policy scoped to desktop Linux so paired web clients on + // those platforms keep their previous IME behavior. + const isLinux = + !isMac && + navigator.userAgent.includes('Linux') && + !/Android|CrOS/.test(navigator.userAgent) const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element) imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker) @@ -864,14 +880,38 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { - if ( - shouldSuppressTerminalImeKeyboardEvent(e, { - compositionActive: imeCompositionTracker.isActive(), - isMac - }) - ) { + const now = Date.now() + const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) + const imeKeyboardOptions = { + compositionActive: imeCompositionTracker.isActive(), + candidateKeyGuardActive: + imeCompositionTracker.isCandidateKeyGuardActive() || + pendingCandidateReleaseGuardActive, + pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive, + isMac, + isLinux + } + if (shouldSuppressTerminalImeKeyboardEvent(e, imeKeyboardOptions)) { + // Why: clear before arm — a fresh keydown drops any stale pending + // release for its key before the new press arms its own. + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) + if (shouldPreventDefaultTerminalImeCandidateKey(e, imeKeyboardOptions)) { + // Why: without preventDefault the suppressed candidate keydown + // still fires a keypress and mutates the helper textarea. + e.preventDefault() + armTerminalImePendingCandidateKeyRelease( + pendingTerminalImeCandidateKeyReleases, + e, + now + ) + } return false } + clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e) if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { pendingTerminalInterruptKeyup = false return false diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts new file mode 100644 index 00000000000..a4548ab5b99 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-event-fixture.ts @@ -0,0 +1,17 @@ +import type { XtermBypassEvent } from './xterm-bypass-policy' + +// Shared test fixture: builds a fully-defaulted XtermBypassEvent so the bypass +// and IME candidate-guard suites all stay in sync when the event shape changes. +export function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index b5b0d40d3cd..7e5d55e4bff 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { const withSel = { isMac: false, hasSelection: true } @@ -161,60 +148,256 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { - const idle = { isMac: false, compositionActive: false } - const composing = { isMac: false, compositionActive: true } + const windowsIdle = { + isMac: false, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxIdle = { + isMac: false, + isLinux: true, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const linuxComposing = { ...linuxIdle, compositionActive: true, candidateKeyGuardActive: true } + // Post-compositionend guard: the tracker is already inactive but the + // committing key's trailing press/release must still be absorbed. + const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } + const windowsComposing = { ...windowsIdle, compositionActive: true, candidateKeyGuardActive: true } it('suppresses keyboard events while Chromium reports active IME composition', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: 'Backspace', code: 'Backspace', isComposing: true }), - idle - ) - ).toBe(true) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace', isComposing: true }), + options + ) + ).toBe(true) + } }) it('suppresses Windows IME Process keys', () => { + // Why: Windows preedit can hit the textarea before compositionstart; + // letting the 229 keydown through would flush it via xterm's textarea diff. expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Process', code: 'KeyN', keyCode: 229 }), - idle + windowsIdle ) ).toBe(true) }) - it('does not suppress ordinary Backspace outside IME composition', () => { + it('lets standalone Linux 229 keydowns reach xterm so its CompositionHelper can diff text', () => { + // Why: Sogou/fcitx candidate commits can ride a bare 229 keydown outside a + // composition session; xterm must see it to schedule its textarea diff. expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), idle) + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxIdle + ) ).toBe(false) }) + it('suppresses Linux 229 keydowns while the composition tracker is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Process', code: 'KeyN', keyCode: 229 }), + linuxComposing + ) + ).toBe(true) + }) + + it('suppresses 229 / Process keyups so kitty release reporting cannot leak', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: 'Process', code: 'KeyN', keyCode: 229 }), + options + ) + ).toBe(true) + } + }) + + it('does not suppress ordinary Backspace outside IME composition', () => { + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'Backspace', code: 'Backspace' }), + options + ) + ).toBe(false) + } + }) + it('suppresses IME-owned editing keys while composition is active', () => { expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'Backspace', code: 'Backspace' }), - composing + linuxComposing ) ).toBe(true) expect( shouldSuppressTerminalImeKeyboardEvent( event({ key: 'ArrowDown', code: 'ArrowDown' }), - composing + linuxComposing ) ).toBe(true) }) it('does not suppress ordinary text keys solely because composition is active', () => { expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), composing) + shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), linuxComposing) ).toBe(false) }) it('does not suppress keypress events because they carry committed text', () => { - expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ type: 'keypress', key: '中', code: '', isComposing: true }), - idle - ) - ).toBe(false) + for (const options of [windowsIdle, linuxIdle]) { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '中', code: '', isComposing: true }), + options + ) + ).toBe(false) + } + }) + + describe('candidate-selection keys (Sogou Space/digit commit)', () => { + it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => { + for (const options of [linuxComposing, linuxPostCompositionGuard]) { + for (const key of [' ', '0', '2', '9']) { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options) + ).toBe(true) + } + } + }) + + it('suppresses the follow-on candidate keypress so _keyPress cannot forward the selector', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: ' ', code: 'Space' }), + linuxPostCompositionGuard + ) + ).toBe(true) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + linuxComposing + ) + ).toBe(true) + }) + + it('leaves Space and digits alone once the guard has expired', () => { + for (const type of ['keydown', 'keyup', 'keypress']) { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ type, key: ' ', code: 'Space' }), linuxIdle) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: '2', code: 'Digit2' }), + linuxIdle + ) + ).toBe(false) + } + }) + + it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', ctrlKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not treat Shift+Space (fcitx full-/half-width toggle) as a candidate key', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space', shiftKey: true }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('leaves ordinary letters unsuppressed while the guard is active', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: 'a', code: 'KeyA' }), + linuxPostCompositionGuard + ) + ).toBe(false) + }) + + it('does not apply the Linux/Sogou candidate guard to Windows', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ key: ' ', code: 'Space' }), + windowsComposing + ) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + windowsComposing + ) + ).toBe(false) + }) + + it('suppresses a pending Linux candidate release even if modifier state changed after keydown', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keyup', key: '2', code: 'Digit2', shiftKey: true }), + { + ...linuxIdle, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: true + } + ) + ).toBe(true) + }) + }) + + describe('shouldPreventDefaultTerminalImeCandidateKey', () => { + it('prevents the default on candidate keydowns while the guard is active', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(true) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: '2', code: 'Digit2' }), + linuxPostCompositionGuard + ) + ).toBe(true) + }) + + it('does not prevent the default for keyups, expired guards, or non-candidate keys', () => { + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ type: 'keyup', key: ' ', code: 'Space' }), + linuxComposing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: 'a', code: 'KeyA' }), linuxComposing) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: ' ', code: 'Space' }), + windowsComposing + ) + ).toBe(false) + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 6d0a70ada5b..dc69ea2578d 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from 'vitest' import { shouldBypassXtermKeyboardEvent, - shouldSuppressTerminalImeKeyboardEvent, - type XtermBypassEvent + shouldPreventDefaultTerminalImeCandidateKey, + shouldSuppressTerminalImeKeyboardEvent } from './xterm-bypass-policy' - -function event(overrides: Partial): XtermBypassEvent { - return { - type: 'keydown', - key: '', - code: '', - defaultPrevented: false, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...overrides - } -} +import { event } from './xterm-bypass-event-fixture' describe('shouldBypassXtermKeyboardEvent — macOS', () => { const opts = { isMac: true, hasSelection: true } @@ -164,8 +151,20 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { }) describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { - const idle = { isMac: true, compositionActive: false } - const composing = { isMac: true, compositionActive: true } + const idle = { + isMac: true, + isLinux: false, + compositionActive: false, + candidateKeyGuardActive: false, + pendingCandidateKeyReleaseActive: false + } + const composing = { + isMac: true, + isLinux: false, + compositionActive: true, + candidateKeyGuardActive: true, + pendingCandidateKeyReleaseActive: false + } it('suppresses keyboard events while Chromium reports active IME composition', () => { expect( @@ -238,4 +237,19 @@ describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { ) ).toBe(false) }) + + it('does not apply the Linux/Sogou candidate guard to macOS', () => { + expect( + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + expect( + shouldSuppressTerminalImeKeyboardEvent( + event({ type: 'keypress', key: '2', code: 'Digit2' }), + composing + ) + ).toBe(false) + expect( + shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), composing) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 57a29c36a4c..34e1240ef84 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard' // Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's // KittyKeyboard encoder turns every modifier chord — including plain Cmd+C — @@ -18,6 +19,7 @@ export type XtermBypassEvent = { code?: string keyCode?: number isComposing?: boolean + repeat?: boolean defaultPrevented?: boolean metaKey: boolean ctrlKey: boolean @@ -35,9 +37,18 @@ export type XtermBypassOptions = { export type XtermImeKeyboardOptions = { compositionActive: boolean + /** True while Linux/Sogou candidate-selection keys (Space/digits) are + * IME-owned: live composition plus a short post-compositionend window. */ + candidateKeyGuardActive: boolean + /** True when the pending-release guard already matched this specific event. */ + pendingCandidateKeyReleaseActive: boolean // Required so no caller silently falls back to non-mac 229 suppression, // which re-swallows the first key after a macOS IME input-source switch. isMac: boolean + // Required Linux/Windows split: Linux passes standalone 229 keydowns like + // macOS; the Windows-only suppression guards its preedit-diff race (preedit + // can hit the textarea before compositionstart and be flushed by the diff). + isLinux: boolean } export const TERMINAL_INTERRUPT_INPUT = '\x03' @@ -74,18 +85,55 @@ export function shouldSuppressTerminalImeKeyboardEvent( event: XtermBypassEvent, options: XtermImeKeyboardOptions ): boolean { + const { + compositionActive, + candidateKeyGuardActive, + pendingCandidateKeyReleaseActive, + isMac, + isLinux + } = options + const suppressCandidateKey = + isLinux && + (pendingCandidateKeyReleaseActive || + (candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event))) + if (event.type === 'keypress') { + // Why: a suppressed candidate keydown is not preventDefault-ed by xterm, + // so its native keypress still fires and _keyPress would forward the + // literal Space/digit to the PTY. + return suppressCandidateKey + } if (!isXtermHandledKeyEvent(event.type)) { return false } - const { compositionActive, isMac } = options // Why: IMEs own Process-key / composing keystrokes — letting xterm translate - // them corrupts committed CJK text. Bare macOS keydown 229 is exempt: it must - // reach xterm's CompositionHelper or the first key after an input-source - // switch is swallowed. + // them corrupts committed CJK text. Bare macOS/Linux keydown 229 is exempt: + // it must reach xterm's CompositionHelper so it can schedule its textarea + // diff (macOS: first key after an input-source switch; Linux: Sogou/fcitx + // candidate commits outside a composition session). Windows keeps full + // suppression until verified against its preedit-diff race. + const passesStandalone229Keydown = isMac || isLinux return ( event.isComposing === true || - (event.keyCode === 229 && (event.type !== 'keydown' || compositionActive || !isMac)) || - (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) + (event.keyCode === 229 && + (event.type !== 'keydown' || compositionActive || !passesStandalone229Keydown)) || + (compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) || + suppressCandidateKey + ) +} + +export function shouldPreventDefaultTerminalImeCandidateKey( + event: XtermBypassEvent, + options: XtermImeKeyboardOptions +): boolean { + // Why: returning false from attachCustomKeyEventHandler does not + // preventDefault — the candidate keydown would still fire a keypress and + // write into the helper textarea, where a later 229 diff could flush the + // leaked selector to the PTY. + return ( + event.type === 'keydown' && + options.isLinux && + options.candidateKeyGuardActive && + isTerminalImeCandidateSelectionKeyEvent(event) ) } diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index edb2fede1a3..02dbb03c112 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -79,6 +79,8 @@ function stripTerminalControls(value: string): string { const CODEX_READY_RE = /Ask Codex|OpenAI/i const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i +const LINUX_IME_POLICY_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36' function terminalImeHarnessScript(runId: string): string { return ` @@ -229,6 +231,9 @@ async function installImeEventProbe(page: Page): Promise { ]) { textarea.addEventListener(type, record, true) } + // Why: the Linux/Sogou post-composition guard lives on the terminal + // element; record terminal-targeted compositionend without doubling key logs. + pane.terminal.element?.addEventListener('compositionend', record, true) }) } @@ -239,6 +244,19 @@ async function readImeEventLog(page: Page): Promise { }) } +async function reloadWithLinuxImePolicy(page: Page): Promise { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { + get: () => userAgent, + configurable: true + }) + }, LINUX_IME_POLICY_USER_AGENT) + // Why: the Sogou repro validates Linux-gated terminal IME policy on macOS + // runners; reload before the terminal pane mounts so lifecycle code sees it. + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +} + async function readPromptState(page: Page): Promise { const content = stripTerminalControls(await getTerminalContent(page, 20_000)) const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)] @@ -320,6 +338,64 @@ async function dispatchImeProcessKey(session: CDPSession, code: string): Promise }) } +async function dispatchCandidateSelectionKey( + session: CDPSession, + candidate: { key: string; code: string; keyCode: number }, + commitBetweenKeys?: () => Promise +): Promise { + // Why: #7543 proves Sogou can forward candidate selectors as plain key + // events, not Process/229. `text` is set so keyDown produces the natural + // keypress — rawKeyDown generates none and would prove nothing about leaks. + await session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode, + text: candidate.key, + unmodifiedText: candidate.key + }) + // Why: committing between keyDown and keyUp keeps xterm's _keyDownSeen set, + // matching the trace shape where an insertText commit is actually at risk. + await commitBetweenKeys?.() + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: candidate.key, + code: candidate.code, + windowsVirtualKeyCode: candidate.keyCode, + nativeVirtualKeyCode: candidate.keyCode + }) +} + +async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise { + // Why: Sogou/fcitx emits empty compositionupdate data while its candidate + // popup is still open (#6765); Orca's tracker must not flip inactive on it. + await page.evaluate(() => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + active.dispatchEvent(new CompositionEvent('compositionupdate', { data: '', bubbles: true })) + }) +} + +async function dispatchSogouPostCompositionEnd(page: Page, data: string): Promise { + // Why: some Sogou/fcitx traces deliver the plain selector key after + // compositionend; target the terminal element so Orca's tracker sees the end + // without making xterm finalize a synthetic preedit string. + await page.evaluate((data) => { + const active = document.activeElement + if (!(active instanceof HTMLTextAreaElement)) { + throw new Error('xterm helper textarea is not focused') + } + const terminalElement = active.closest('.xterm') + if (!(terminalElement instanceof HTMLElement)) { + throw new Error('xterm terminal element was not found') + } + terminalElement.dispatchEvent(new CompositionEvent('compositionend', { data, bubbles: false })) + }, data) +} + async function composeAndCommitChineseText( session: CDPSession, page: Page, @@ -473,6 +549,124 @@ test.describe('Chinese IME terminal chat input repro', () => { } }) + test('keeps Sogou-style candidate selection keys out of the PTY while committing Chinese text', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await reloadWithLinuxImePolicy(orcaPage) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-sogou-ime-harness-${runId}.cjs`) + let session: CDPSession | null = null + let harnessStarted = false + + try { + // Why: create the session/harness inside the try so a mid-setup throw + // still hits finally and removes the harness script. + writeFileSync(scriptPath, terminalImeHarnessScript(runId)) + session = await orcaPage.context().newCDPSession(orcaPage) + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + harnessStarted = true + await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000) + await focusActiveTerminalInput(orcaPage) + await installImeEventProbe(orcaPage) + + // Space selects the first candidate. Sogou keeps emitting empty + // compositionupdate frames while the popup is open, and the plain Space + // press arrives around the commit rather than as a Process/229 key. + await setImeComposition(session, 'n') + await orcaPage.waitForTimeout(80) + await setImeComposition(session, 'ni') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: ' ', code: 'Space', keyCode: 32 }, () => + commitImeText(session, '你') + ) + await waitForLivePrompt(orcaPage, '你') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-space-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'space-selected candidate did not submit the committed Chinese character' + }) + .toBe('你') + + // Digit selects a non-first candidate for a word/phrase commit — the + // #7543 shape where only the number used to reach the TUI. + await setImeComposition(session, 'nihao') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchCandidateSelectionKey(session, { key: '2', code: 'Digit2', keyCode: 50 }, () => + commitImeText(session, '你好') + ) + await waitForLivePrompt(orcaPage, '你好') + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'digit-selected candidate did not submit the committed Chinese phrase' + }) + .toBe('你好') + + // Post-composition traces can deliver the selector after compositionend; + // the short post-end guard must still keep that plain digit out of the PTY. + const postCompositionLogStart = (await readImeEventLog(orcaPage)).length + await setImeComposition(session, 'zaijian') + await orcaPage.waitForTimeout(80) + await dispatchSogouEmptyCompositionUpdate(orcaPage) + await dispatchSogouPostCompositionEnd(orcaPage, '再见') + await dispatchCandidateSelectionKey(session, { key: '3', code: 'Digit3', keyCode: 51 }, () => + commitImeText(session, '再见') + ) + await waitForLivePrompt(orcaPage, '再见') + const postCompositionLog = await readImeEventLog(orcaPage) + const postCompositionEndIndex = postCompositionLog.findIndex( + (entry, index) => + index >= postCompositionLogStart && entry.type === 'compositionend' && entry.data === '再见' + ) + const postCompositionSelectorIndex = postCompositionLog.findIndex( + (entry, index) => + index > postCompositionEndIndex && entry.type === 'keydown' && entry.key === '3' + ) + expect( + postCompositionEndIndex, + 'Sogou post-composition repro must dispatch compositionend before the plain selector' + ).toBeGreaterThanOrEqual(postCompositionLogStart) + expect( + postCompositionSelectorIndex, + 'plain digit selector must arrive after compositionend in the post-composition repro' + ).toBeGreaterThan(postCompositionEndIndex) + await attachImeEvidence(orcaPage, testInfo, 'sogou-after-post-composition-digit-commit') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, { + timeout: 5_000, + message: 'post-composition digit-selected candidate did not submit cleanly' + }) + .toBe('再见') + + const promptState = await readPromptState(orcaPage) + expect( + promptState?.submitted, + 'candidate Space/digit selectors and pinyin preedit must not leak into the PTY' + ).toEqual(['你', '你好', '再见']) + } finally { + await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined) + await session?.detach().catch(() => undefined) + if (harnessStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('keeps composed Chinese text stable in the real Codex TUI input @real-codex-ime', async ({ orcaPage }, testInfo) => { From eed00581686290480dd7d445bf3708644cb45ff0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:04:32 +0000 Subject: [PATCH 41/46] release: v1.4.127-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a793e5c51af..d34b0b68891 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.126", + "version": "1.4.127-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From af59239c1a58a2ee494c0022f06a61765ca6fb1b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:01:51 -0700 Subject: [PATCH 42/46] test(e2e): relax hidden PTY timer drift gate --- .../e2e/artificial-opencode-hidden-pressure-scenario.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 0e67a67bb6d..90fa83b8741 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -79,6 +79,9 @@ type HiddenPressureAckGate = { // a loaded OSS runner the drain-plus-poll overhead was seen at ~3.2s, so keep a // ceiling with headroom that still catches an order-of-magnitude regression. const MAX_HIDDEN_RESTORE_LATENCY_MS = 4_000 +// Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key +// latency catch the same isolated CI starvation spike; median remains strict. +const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 export function pressureOutputScript(runId: string): string { return ` @@ -215,11 +218,7 @@ export async function runHiddenRealPtyPressureScenario< // detector — the original regression (input freezing for seconds) shows up in // the median too. Aligns with ssh-docker-relay-perf's 2s worst-key tolerance. expect(measurement.worstLatencyMs).toBeLessThan(3_000) - // Why: maxTimerDriftMs is a single-worst-tick metric that spikes on a loaded - // OSS runner (seen at 155ms under 8MB of in-flight backpressure). Median above - // is the real responsiveness guard; align the spike tolerance with the sibling - // terminal-load suite's MAX_TIMER_DRIFT_MS. - expect(measurement.maxTimerDriftMs).toBeLessThan(250) + expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS) await deps.releaseTerminalAckGate(orcaPage) const restoreLatencyMs = await measureHiddenOutputRestoreLatency( From cf7fb4f7bb370aa0a45314a7378313d76f53f4ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:28:26 +0000 Subject: [PATCH 43/46] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 3f124029e89..d8d797085e2 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.7m + + downloads: 3.8m @@ -15,7 +15,7 @@ downloads downloads - 3.7m - 3.7m + 3.8m + 3.8m From 2dad1f04f375237a532b080b6e819a094652b339 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:57:48 +0000 Subject: [PATCH 44/46] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d8d797085e2..4afaca9293c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.8m + + downloads: 3.9m @@ -15,7 +15,7 @@ downloads downloads - 3.8m - 3.8m + 3.9m + 3.9m From 0f7658d77c06ada39bf109b298ecbe11f4f4f1ee Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:12:00 -0700 Subject: [PATCH 45/46] Make center split divider more visible (#7701) * fix: address review findings * Improve split divider contrast and refactor tab split context menus - Increase contrast of `--tab-group-split-divider` colors in light and dark modes to achieve at least 3:1 contrast against `--card`. - Refactor `TerminalTabSplitMenuSection` to use the shared `TabWorkspaceLayoutMenuSection` for moving tabs between splits. - Clarify terminal-specific split action labels in the context menu. - Remove redundant icon margins in `EditorFileTabContextMenu`. --- src/renderer/src/assets/main.css | 56 ++++++++++++++ .../components/tab-bar/BrowserTab.test.tsx | 15 ++++ .../src/components/tab-bar/BrowserTab.tsx | 23 +++--- .../components/tab-bar/EditorFileTab.test.tsx | 18 +++++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 24 ++++++ .../tab-bar/EditorFileTabContextMenu.tsx | 32 ++++++-- .../SortableTab.rename-shortcut.test.tsx | 24 ++++++ .../tab-bar/SortableTabContextMenu.test.tsx | 16 +++- .../tab-bar/SortableTabContextMenu.tsx | 61 +++++++--------- .../tab-bar/TabWorkspaceLayoutMenuSection.tsx | 24 +++++- .../tab-bar/TerminalTabSplitMenuSection.tsx | 73 +++++++++++++++++++ .../components/tab-group/TabGroupPanel.tsx | 16 +++- .../tab-group/TabGroupSplitLayout.tsx | 32 +++++++- src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + 18 files changed, 368 insertions(+), 61 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f028b641384..b77b58a5c1a 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -101,6 +101,8 @@ --color-status-success: var(--status-success); --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); + --color-tab-group-split-divider: var(--tab-group-split-divider); + --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong); --color-git-graph-ref: var(--git-graph-ref); --color-git-graph-remote-ref: var(--git-graph-remote-ref); --color-git-graph-base-ref: var(--git-graph-base-ref); @@ -202,6 +204,11 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #f59e0b; + /* Why: tab-group splits use a dedicated divider — `--border` is too faint on + card/editor surfaces when panes are side-by-side or stacked. Default must + meet >=3:1 against `--card`, not only the hover/drag strong state. */ + --tab-group-split-divider: #868690; + --tab-group-split-divider-strong: #71717a; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -285,6 +292,9 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #fbbf24; + /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */ + --tab-group-split-divider: #71717a; + --tab-group-split-divider-strong: #a1a1aa; } .linear-priority-bars { @@ -618,6 +628,52 @@ overflow: hidden; } +/* Tab-group split resize handle — wide hit area, visible center line. */ +.tab-group-split-resize-handle { + flex-shrink: 0; + position: relative; + background: transparent; +} + +.tab-group-split-resize-handle.is-vertical { + width: 6px; + cursor: col-resize; +} + +.tab-group-split-resize-handle.is-horizontal { + height: 6px; + cursor: row-resize; +} + +.tab-group-split-resize-handle::after { + content: ''; + position: absolute; + border-radius: 1px; + background: var(--tab-group-split-divider); + transition: background 100ms ease; +} + +.tab-group-split-resize-handle.is-vertical::after { + top: 0; + bottom: 0; + left: 50%; + width: 3px; + transform: translateX(-50%); +} + +.tab-group-split-resize-handle.is-horizontal::after { + left: 0; + right: 0; + top: 50%; + height: 3px; + transform: translateY(-50%); +} + +.tab-group-split-resize-handle:hover::after, +.tab-group-split-resize-handle.is-dragging::after { + background: var(--tab-group-split-divider-strong); +} + /* ── Titlebar ────────────────────────────────────────── */ /* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index f4dc863f8e8..2dfd84d87e5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -42,6 +42,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -60,6 +72,9 @@ vi.mock('lucide-react', () => ({ PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Rows2: function Rows2(props: Record) { return { type: 'Rows2', props } }, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 0dfb97a9af6..45bc82e7989 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react' +import { Globe, X, ExternalLink, Copy, Pin, PinOff, PanelRightClose } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -279,37 +279,36 @@ export default function BrowserTab({ sideOffset={0} align="start" > + - + {translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')} - {isPinned ? ( - - ) : ( - - )} + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab') : translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} void window.api.shell.openUrl(openInBrowserUrl)} disabled={!isHttpUrl} > - + {translate('auto.components.tab.bar.BrowserTab.6e0bc8f3a8', 'Open In Browser')} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 59a1c18bcee..66071a7da1d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -58,6 +58,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -70,6 +82,12 @@ vi.mock('lucide-react', () => ({ Eye: function Eye(props: Record) { return { type: 'Eye', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, GitCompareArrows: function GitCompareArrows(props: Record) { return { type: 'GitCompareArrows', props } }, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 8167bdbda5c..09257498698 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -36,12 +36,33 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Copy: function Copy(props: Record) { return { type: 'Copy', props } }, ExternalLink: function ExternalLink(props: Record) { return { type: 'ExternalLink', props } }, + Eye: function Eye(props: Record) { + return { type: 'Eye', props } + }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + PanelRightClose: function PanelRightClose(props: Record) { + return { type: 'PanelRightClose', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -56,6 +77,9 @@ vi.mock('lucide-react', () => ({ }, PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } + }, + X: function X(props: Record) { + return { type: 'X', props } } })) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 184c05d2d97..bbf9a75d74a 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -1,4 +1,14 @@ -import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react' +import { + Copy, + ExternalLink, + Eye, + ListX, + PanelRightClose, + Pencil, + Pin, + PinOff, + X +} from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -107,6 +117,11 @@ export function EditorFileTabContextMenu({ event.preventDefault() }} > + { @@ -115,24 +130,25 @@ export function EditorFileTabContextMenu({ onOpenRenameInput() }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} {renameShortcut ? {renameShortcut} : null} - {isPinned ? : } + {isPinned ? : } {isPinned ? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab') : translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')} - !isPinned && onClose()} disabled={isPinned}> + {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} {closeShortcut ? {closeShortcut} : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' @@ -142,6 +158,7 @@ export function EditorFileTabContextMenu({ ) : null} + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.e5ff31ccaf', 'Close Tabs To The Right' @@ -165,6 +182,7 @@ export function EditorFileTabContextMenu({ ) }} > + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.bfd5797ef4', 'Open Markdown Preview' @@ -178,7 +196,7 @@ export function EditorFileTabContextMenu({ void window.api.ui.writeClipboardText(file.filePath) }} > - + {translate('auto.components.tab.bar.EditorFileTabContextMenu.5b85754786', 'Copy Path')} - + {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.52ce4f4605', 'Copy Relative Path' @@ -208,7 +226,7 @@ export function EditorFileTabContextMenu({ window.api.shell.openPath(file.filePath) }} > - + {revealLabel} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index bb4e757d1c8..d183891b6de 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -72,6 +72,18 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: function ArrowDown(props: Record) { + return { type: 'ArrowDown', props } + }, + ArrowLeft: function ArrowLeft(props: Record) { + return { type: 'ArrowLeft', props } + }, + ArrowRight: function ArrowRight(props: Record) { + return { type: 'ArrowRight', props } + }, + ArrowUp: function ArrowUp(props: Record) { + return { type: 'ArrowUp', props } + }, Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, @@ -84,6 +96,15 @@ vi.mock('lucide-react', () => ({ PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, + ListX: function ListX(props: Record) { + return { type: 'ListX', props } + }, + MessageSquare: function MessageSquare(props: Record) { + return { type: 'MessageSquare', props } + }, + Pencil: function Pencil(props: Record) { + return { type: 'Pencil', props } + }, Pin: function Pin(props: Record) { return { type: 'Pin', props } }, @@ -95,6 +116,9 @@ vi.mock('lucide-react', () => ({ }, X: function X(props: Record) { return { type: 'X', props } + }, + SquareTerminal: function SquareTerminal(props: Record) { + return { type: 'SquareTerminal', props } } })) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 71b067fa898..6fb14821294 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -50,10 +50,20 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) vi.mock('lucide-react', () => ({ + ArrowDown: () => null, + ArrowLeft: () => null, + ArrowRight: () => null, + ArrowUp: () => null, + Columns2: () => null, + ListX: () => null, + MessageSquare: () => null, PanelBottomClose: () => null, PanelRightClose: () => null, + Pencil: () => null, Pin: () => null, - PinOff: () => null + PinOff: () => null, + SquareTerminal: () => null, + X: () => null })) vi.mock('@/i18n/i18n', () => ({ @@ -218,6 +228,7 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal') act(() => getButton(container, 'Right').click()) expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', { @@ -226,7 +237,7 @@ describe('SortableTabContextMenu', () => { }) }) - it('hides split actions for a single-tab group', () => { + it('hides move-tab split actions for a single-tab group', () => { storeMock.state = { ...storeMock.state, groupsByWorktree: { @@ -243,5 +254,6 @@ describe('SortableTabContextMenu', () => { const { container } = renderMenu() expect(container.textContent).not.toContain('Move Tab to Split') + expect(container.textContent).toContain('Split terminal right') }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index f93afd46d89..469c4f7159a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,10 +1,12 @@ import { MessageSquare, - PanelBottomClose, PanelRightClose, Pin, PinOff, - SquareTerminal + Pencil, + SquareTerminal, + X, + ListX } from 'lucide-react' import { DropdownMenu, @@ -18,8 +20,7 @@ import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' -import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' -import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' +import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' const TAB_COLORS = [ { @@ -138,12 +139,6 @@ export function SortableTabContextMenu({ const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) - const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { - if (!isActive) { - onActivate(tab.id) - } - requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) - } const closeShortcut = useOptionalShortcutLabel('tab.close') const renameShortcut = useOptionalShortcutLabel('tab.rename') @@ -158,13 +153,23 @@ export function SortableTabContextMenu({ /> - {canToggleViewMode && onToggleViewMode && ( + + {canToggleViewMode && onToggleViewMode ? ( <> + {isChatView ? ( - + ) : ( - + )} {isChatView ? translate( @@ -176,42 +181,31 @@ export function SortableTabContextMenu({ 'Switch to chat view' )} - - )} - splitActiveTerminalPane('vertical')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', - 'Split terminal right' - )} - {splitRightShortcut} - - splitActiveTerminalPane('horizontal')}> - - {translate( - 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', - 'Split terminal down' - )} - {splitDownShortcut} - - + ) : null} - {isPinned ? : } + {isPinned ? ( + + ) : ( + + )} {isPinned ? translate('auto.components.tab.bar.SortableTabContextMenu.417722e9c2', 'Unpin Tab') : translate('auto.components.tab.bar.SortableTabContextMenu.60f958ec75', 'Pin Tab')} !isPinned && onClose(tab.id)} disabled={isPinned}> + {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> + {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} onCloseToRight(tab.id)} disabled={!hasTabsToRight}> + {translate( 'auto.components.tab.bar.SortableTabContextMenu.c1ee099c7e', 'Close Tabs To The Right' @@ -219,6 +213,7 @@ export function SortableTabContextMenu({ + {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx index 819bb6d328f..b1ae2d1cf28 100644 --- a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -5,12 +5,26 @@ import { DropdownMenuSubTrigger, DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns2 } from 'lucide-react' import type { TabSplitDirection } from '../../store/slices/tabs' import { translate } from '@/i18n/i18n' import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column' const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up'] +function paneColumnDirectionIcon(direction: TabSplitDirection): React.JSX.Element { + switch (direction) { + case 'right': + return + case 'left': + return + case 'down': + return + case 'up': + return + } +} + function paneColumnDirectionLabel(direction: TabSplitDirection): string { switch (direction) { case 'right': @@ -26,10 +40,12 @@ function paneColumnDirectionLabel(direction: TabSplitDirection): string { export function TabWorkspaceLayoutMenuSection({ unifiedTabId, - groupId + groupId, + trailingSeparator = false }: { unifiedTabId: string groupId: string + trailingSeparator?: boolean }): React.JSX.Element | null { if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) { return null @@ -37,9 +53,9 @@ export function TabWorkspaceLayoutMenuSection({ return ( <> - - + + {translate( 'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn', 'Move Tab to Split' @@ -53,11 +69,13 @@ export function TabWorkspaceLayoutMenuSection({ moveTabToNewPaneColumn({ unifiedTabId, groupId, direction }) }} > + {paneColumnDirectionIcon(direction)} {paneColumnDirectionLabel(direction)} ))} + {trailingSeparator ? : null} ) } diff --git a/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx new file mode 100644 index 00000000000..7b75e42f4bc --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabSplitMenuSection.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { PanelBottomClose, PanelRightClose, SquareTerminal } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' + +export function TerminalTabSplitMenuSection({ + unifiedTabId, + groupId, + tabId, + isActive, + onActivate, + splitRightShortcut, + splitDownShortcut, + trailingSeparator = false +}: { + unifiedTabId: string + groupId: string + tabId: string + isActive: boolean + onActivate: (tabId: string) => void + splitRightShortcut: string + splitDownShortcut: string + trailingSeparator?: boolean +}): React.JSX.Element { + const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => { + if (!isActive) { + onActivate(tabId) + } + requestActiveTerminalPaneSplit({ tabId, direction }) + } + + return ( + <> + + + + + {translate( + 'auto.components.tab.bar.TerminalTabSplitMenuSection.splitTerminal', + 'Split terminal' + )} + + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} + + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + + + + {trailingSeparator ? : null} + + ) +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 57dbf5946fc..e6f237c9bc3 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -29,6 +29,10 @@ export default function TabGroupPanel({ hasSplitGroups, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge = false, + suppressLeftBorder = false, + suppressRightBorder = false, + suppressBottomBorder = false, reserveClosedExplorerToggleSpace, reserveCollapsedSidebarHeaderSpace, isTabDragActive = false, @@ -40,6 +44,10 @@ export default function TabGroupPanel({ hasSplitGroups: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge?: boolean + suppressLeftBorder?: boolean + suppressRightBorder?: boolean + suppressBottomBorder?: boolean reserveClosedExplorerToggleSpace: boolean reserveCollapsedSidebarHeaderSpace: boolean isTabDragActive?: boolean @@ -205,7 +213,13 @@ export default function TabGroupPanel({ // border-l/border-r in those spots stacks a second 1px line // next to it, reading as a ~2px bar below the drag strip // (where the sibling border continues alone above). - ` ${touchesLeftEdge ? '' : 'border-l'} ${touchesRightEdge ? '' : 'border-r'} border-border border-b ${isFocused ? 'border-b-accent' : 'opacity-95'}` + ` ${ + touchesLeftEdge || suppressLeftBorder ? '' : 'border-l' + } ${touchesRightEdge || suppressRightBorder ? '' : 'border-r'} ${ + touchesBottomEdge || suppressBottomBorder ? '' : 'border-b' + } border-border ${ + isFocused && !touchesBottomEdge && !suppressBottomBorder ? 'border-b-accent' : '' + } ${isFocused ? '' : 'opacity-95'}` : '' }`} onPointerDown={commands.focusGroup} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index db37a19d241..04cc6536249 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -103,9 +103,9 @@ function ResizeHandle({ return (
) @@ -121,6 +121,10 @@ function SplitNode({ touchesTopEdge, touchesRightEdge, touchesLeftEdge, + touchesBottomEdge, + suppressLeftBorder, + suppressRightBorder, + suppressBottomBorder, isTabDragActive, hoveredTabInsertion }: { @@ -133,6 +137,10 @@ function SplitNode({ touchesTopEdge: boolean touchesRightEdge: boolean touchesLeftEdge: boolean + touchesBottomEdge: boolean + suppressLeftBorder: boolean + suppressRightBorder: boolean + suppressBottomBorder: boolean isTabDragActive: boolean hoveredTabInsertion: HoveredTabInsertion | null }): React.JSX.Element { @@ -152,6 +160,10 @@ function SplitNode({ hasSplitGroups={hasSplitGroups} touchesRightEdge={touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge} reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge} isTabDragActive={isTabDragActive} @@ -181,6 +193,12 @@ function SplitNode({ touchesTopEdge={touchesTopEdge} touchesRightEdge={isHorizontal ? false : touchesRightEdge} touchesLeftEdge={touchesLeftEdge} + touchesBottomEdge={isHorizontal ? touchesBottomEdge : false} + suppressLeftBorder={suppressLeftBorder} + // Why: the resize handle paints the inner seam — pane borders here + // stack into a triple-line bar beside the divider. + suppressRightBorder={isHorizontal ? true : suppressRightBorder} + suppressBottomBorder={isHorizontal ? suppressBottomBorder : true} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -201,6 +219,10 @@ function SplitNode({ touchesTopEdge={isHorizontal ? touchesTopEdge : false} touchesRightEdge={touchesRightEdge} touchesLeftEdge={isHorizontal ? false : touchesLeftEdge} + touchesBottomEdge={touchesBottomEdge} + suppressLeftBorder={isHorizontal ? true : suppressLeftBorder} + suppressRightBorder={suppressRightBorder} + suppressBottomBorder={suppressBottomBorder} isTabDragActive={isTabDragActive} hoveredTabInsertion={hoveredTabInsertion} /> @@ -275,6 +297,10 @@ export default function TabGroupSplitLayout({ touchesTopEdge={true} touchesRightEdge={true} touchesLeftEdge={true} + touchesBottomEdge={false} + suppressLeftBorder={false} + suppressRightBorder={false} + suppressBottomBorder={false} isTabDragActive={dragSplit.activeDrag !== null} hoveredTabInsertion={dragSplit.hoveredTabInsertion} /> diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7ea7146ec6f..d15a81c4661 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Close tab", "a768f428f1": "Close tab" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cc51dd41b99..84d5a861a54 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "Cerrar pestaña", "a768f428f1": "Cerrar pestaña" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a70953da324..20e253a584d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "タブを閉じる", "a768f428f1": "タブを閉じる" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 511f3b1e3c8..cd83a58fa86 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "탭 닫기", "a768f428f1": "탭 닫기" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d46dfc98f40..5be00891377 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2771,6 +2771,9 @@ "EditorFileTabCloseButton": { "4655cf570e": "关闭选项卡", "a768f428f1": "关闭选项卡" + }, + "TerminalTabSplitMenuSection": { + "splitTerminal": "Split terminal" } } }, From 599f9f2138d800e961e3efa82eb1944a24b41737 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:33:41 -0700 Subject: [PATCH 46/46] Update WeChat community info in localized READMEs - Remove the WeChat community section from the Portuguese translation. - Add the main and backup WeChat QR codes to the Chinese translation. --- docs/readme/README.pt.md | 4 ---- docs/readme/README.zh-CN.md | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 6c3fc52354c..3ba8d351bf9 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios. -- **WeChat:** Escaneie o QR code para entrar na comunidade. - - QR code do WeChat para a comunidade Orca - - **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues). - **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar. - **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 4c538b08f22..d17ae0a8df8 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,6 +235,11 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 +- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 + + Orca 社区微信群二维码 + Orca 社区备用微信群二维码 + - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 - **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。