From 2f26e61647b5a90ef72a5758547d7bcc6da7b6e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:04:50 -0700 Subject: [PATCH 001/114] fix(dashboard): keep agent map rings open while the pointer works inside them (#13682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): keep agent map rings open while the pointer works inside them The project and workspace rings drove their hover styling from `:hover` on the ring element itself. A ring is a sibling of everything drawn inside it, so the style dropped the moment the pointer crossed onto a workspace or an agent, and a pan drag killed it outright — pointer capture retargets `:hover` to the `` for the whole gesture. Both read as the ring pulsing open and shut. Move the trigger to the containing group, wrapped in `:where()` so the existing `.is-open` / `.is-selected` / status rules keep winning the same properties, and add `.is-held` for the pan, driven by which rings the press landed in. The hold releases on the first move after the button comes up, when the browser recomputes `:hover` in that same event, so the two signals never both read off. Hovering a workspace now also always shows its name and agent count. That label was gated behind the declutter pass, which suppresses any label overlapping an agent — at fleet zoom that is nearly all of them, so hover revealed nothing. It now bypasses declutter and draws in a layer above every project, so a neighbouring ring cannot wash it out. * fix(dashboard): complete agent map ring interaction lifecycle --- .../dashboard-popout/AgentMap.test.tsx | 162 ++---------------- .../dashboard-popout/AgentMapCanvas.tsx | 26 ++- .../AgentMapProjectLabel.test.tsx | 2 + .../AgentMapRingHover.test.tsx | 124 ++++++++++++++ .../dashboard-popout/AgentMapScene.tsx | 76 ++++++-- .../AgentMapStatusGlow.test.tsx | 26 +++ .../AgentMapWorktreeLabel.tsx | 3 +- .../AgentMapWorktreeRingNode.tsx | 20 ++- .../agent-map-hover-containment.test.ts | 49 ++++++ .../agent-map-label-declutter.test.ts | 13 -- .../agent-map-label-declutter.ts | 6 +- .../agent-map-render-test-harness.tsx | 133 ++++++++++++++ .../components/dashboard-popout/agent-map.css | 34 +++- .../useAgentMapPointerHold.ts | 47 +++++ 14 files changed, 517 insertions(+), 204 deletions(-) create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index a100ecaabef..699a698f008 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -1,137 +1,16 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - DashboardCard, - DashboardSleepWorkspaceArgs, - DashboardSpawnAgentArgs -} from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { AgentMap } from './AgentMap' import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' import type { AgentMapState } from './agent-map-filter' import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' - -const NOW = 2_000_000_000 - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'pane-1', - ptyId: 'pty-1', - agentType: 'codex', - bucket: 'working', - dotState: 'working', - task: 'Build map', - repoId: 'repo-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - repoName: 'Orca', - worktreeName: 'Agent map', - conversationName: 'Agent alpha', - startedAt: NOW - 10 * 60_000, - finishedAt: null, - stateChangedAt: NOW - 1_000, - unseen: false, - hostKind: 'local', - workspaceKind: 'worktree', - ...overrides - } -} - -function renderMap( - cards: DashboardCard[], - { - onOpenTerminal = vi.fn(), - selectedPaneKey = null, - compact = false, - workspaceContextMenusEnabled = false, - enabledStates, - showOrchestrationLinks, - launchableAgentsByWorktreeId, - onSpawnAgent, - onSleepWorkspace - }: { - onOpenTerminal?: (card: DashboardCard) => void - selectedPaneKey?: string | null - compact?: boolean - workspaceContextMenusEnabled?: boolean - enabledStates?: ReadonlySet - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void - } = {} -): ReturnType { - return render( - - ) -} +import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness' describe('AgentMap', () => { - const originalUserAgent = navigator.userAgent - let boundsSpy: ReturnType - - beforeEach(() => { - Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) - ) - boundsSpy = vi - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(function getBounds(this: Element) { - if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 400, - bottom: 300, - width: 400, - height: 300, - toJSON: () => ({}) - } - } - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - toJSON: () => ({}) - } - }) - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.unstubAllGlobals() - boundsSpy.mockRestore() - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: originalUserAgent - }) - }) + const environment = installAgentMapEnvironment() it('renders the amber marker only for unread agents', () => { const finished = card({ @@ -164,23 +43,6 @@ describe('AgentMap', () => { expect(doneNode).toHaveAccessibleName(/unread/) }) - it.each([ - { bucket: 'working', dotState: 'working', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, - { bucket: 'done', dotState: 'done', unseen: true, glows: false }, - { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } - ] as const)('applies the expected halo for $dotState agents', (state) => { - const { container } = renderMap([card(state)]) - const glow = container.querySelector('[data-agent-map-agent-status-glow]') - - if (state.glows) { - expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) - return - } - expect(glow).not.toBeInTheDocument() - }) - it('lets attention override working on the worktree glow', () => { const done = card({ paneKey: 'done', @@ -631,13 +493,13 @@ describe('AgentMap', () => { labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - const readsBeforeZoom = boundsSpy.mock.calls.length + const readsBeforeZoom = environment.boundsSpy.mock.calls.length fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) const zoomedScale = Number( labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) expect(zoomedScale).toBeGreaterThan(initialScale) }) @@ -656,18 +518,18 @@ describe('AgentMap', () => { releasePointerCapture: vi.fn() }) - const readsBeforeHover = boundsSpy.mock.calls.length + const readsBeforeHover = environment.boundsSpy.mock.calls.length fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) expect(svg.setPointerCapture).toHaveBeenCalledWith(1) - const readsAfterPointerDown = boundsSpy.mock.calls.length + const readsAfterPointerDown = environment.boundsSpy.mock.calls.length const initialViewBox = svg.getAttribute('viewBox') fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) expect(requestFrame).toHaveBeenCalledOnce() expect(svg).toHaveAttribute('viewBox', initialViewBox) @@ -675,7 +537,7 @@ describe('AgentMap', () => { expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) requestFrame.mockClear() - const readsBeforeWheel = boundsSpy.mock.calls.length + const readsBeforeWheel = environment.boundsSpy.mock.calls.length const wheel = (): void => { const event = new Event('wheel', { bubbles: true, cancelable: true }) Object.defineProperties(event, { @@ -688,7 +550,7 @@ describe('AgentMap', () => { } wheel() wheel() - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) expect(requestFrame).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index b3203ebf701..3b71f0f2006 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -28,6 +28,7 @@ import { import type { AgentMapViewport } from './agent-map-viewport-transition' import { useAgentMapContextMenus } from './useAgentMapContextMenus' import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapPointerHold } from './useAgentMapPointerHold' import { useAgentMapMotionLayout } from './useAgentMapMotionLayout' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' @@ -86,6 +87,7 @@ export const AgentMapCanvas = forwardRef(null) const interactionBoundsRef = useRef(null) const hasShownProjectsRef = useRef(layout.projects.length > 0) + const { held, hold, release: releaseHold, clearDrag } = useAgentMapPointerHold(dragRef) const clearInteractionBounds = useCallback(() => { interactionBoundsRef.current = null }, []) @@ -315,7 +317,7 @@ export const AgentMapCanvas = forwardRef { - if (event.button !== 0) { + if (event.button !== 0 || dragRef.current) { return } if ( @@ -337,11 +339,16 @@ export const AgentMapCanvas = forwardRef { const drag = dragRef.current - if (!drag || drag.pointerId !== event.pointerId) { + if (!drag) { + releaseHold() + return + } + if (drag.pointerId !== event.pointerId) { return } scheduleViewport({ @@ -353,14 +360,19 @@ export const AgentMapCanvas = forwardRef { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + if (clearDrag(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } }} onPointerCancel={(event) => { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + clearDrag(event.pointerId) + }} + onLostPointerCapture={(event) => { + clearDrag(event.pointerId) + }} + onPointerLeave={() => { + if (!dragRef.current) { + releaseHold() } }} > @@ -370,6 +382,8 @@ export const AgentMapCanvas = forwardRef { zoom={1} labelScale={1} mapScale={0.5} + heldProjectId={null} + heldWorktreeId={null} selectedPaneKey={null} allowAggregation showOrchestrationLinks diff --git a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx new file mode 100644 index 00000000000..ccc3b29e89f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +/** A ring has to stay open for as long as the pointer is working inside it — + * across its own contents, and across a pan drag that takes pointer capture. */ +describe('AgentMap ring hover', () => { + installAgentMapEnvironment() + + it('reveals the hovered workspace name and agent count above every other label', () => { + const { container } = renderMap([ + card(), + card({ paneKey: 'pane-2', conversationName: 'Agent beta' }) + ]) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + + fireEvent.pointerOver(container.querySelector('.agent-map-worktree-group')!) + + const hovered = container.querySelector('[data-agent-map-hover-label]')! + expect(hovered.querySelector('.agent-map-worktree-label')).toHaveTextContent('Agent map') + expect(hovered.querySelector('.agent-map-worktree-count')).toHaveTextContent('2 agents') + expect(hovered.querySelector('.agent-map-worktree-label-group')).toHaveClass( + 'is-active', + 'is-count-visible' + ) + // The hovered name is hoisted, not duplicated, and draws after every ring. + const labels = container.querySelectorAll('.agent-map-worktree-label-group') + expect(labels).toHaveLength(1) + const lastRing = [...container.querySelectorAll('[data-agent-map-worktree]')].at(-1)! + expect(lastRing.compareDocumentPosition(labels[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + }) + + it('hides the hovered workspace label again when the pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + + fireEvent.pointerOver(group) + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + + fireEvent.pointerOut(group) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + }) + + it('keeps the pressed rings lit for the whole pan drag', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + const worktreeGroup = container.querySelector('.agent-map-worktree-group')! + // Pointer capture retargets :hover to the mid-gesture, so CSS alone + // cannot hold the ring open — the class has to survive the drag. + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerUp(svg, { pointerId: 1 }) + expect(projectRing).not.toHaveClass('is-held') + expect(worktreeGroup).not.toHaveClass('is-held') + }) + + it('holds the workspace name up while panning from inside that workspace', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const worktreeGroup = container.querySelector('[data-agent-map-worktree-id]')! + // The aggregate bubble sits inside the group but is not the ring, so a press + // there pans rather than opening the workspace popover. + fireEvent.pointerDown(worktreeGroup, { button: 0, pointerId: 1 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + + expect(worktreeGroup).toHaveClass('is-held') + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('drops the held rings when the pan is cancelled', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.pointerCancel(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('drops the held rings and drag when pointer capture is lost', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.lostPointerCapture(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('keeps a focused workspace label visible after its pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + ring.focus() + fireEvent.pointerOver(group) + fireEvent.pointerOut(group) + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('keeps a hovered workspace label visible after its focus leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + fireEvent.pointerOver(group) + ring.focus() + ring.blur() + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index fdd80ff982e..2181e4c0f2c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -22,6 +22,9 @@ type AgentMapSceneProps = { zoom: number labelScale: number mapScale: number + /** Rings the pointer was pressed in; they stay lit for the whole pan drag. */ + heldProjectId: string | null + heldWorktreeId: string | null selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -63,6 +66,8 @@ export const AgentMapScene = memo(function AgentMapScene({ zoom, labelScale, mapScale, + heldProjectId, + heldWorktreeId, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -74,9 +79,16 @@ export const AgentMapScene = memo(function AgentMapScene({ onOpenWorkspaceContextMenu, onAgentKeyDown }: AgentMapSceneProps): React.JSX.Element { - const [activeWorktreeId, setActiveWorktreeId] = useState(null) - const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { - setActiveWorktreeId((current) => + const [hoveredWorktreeId, setHoveredWorktreeId] = useState(null) + const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) + const activeWorktreeId = heldWorktreeId ?? hoveredWorktreeId ?? focusedWorktreeId + const handleLabelHoverChange = useCallback((worktreeId: string, active: boolean): void => { + setHoveredWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const handleLabelFocusChange = useCallback((worktreeId: string, active: boolean): void => { + setFocusedWorktreeId((current) => active ? worktreeId : current === worktreeId ? null : current ) }, []) @@ -84,6 +96,19 @@ export const AgentMapScene = memo(function AgentMapScene({ () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), [labelScale, layout, mapScale] ) + const activeWorktree = useMemo(() => { + if (!activeWorktreeId) { + return null + } + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (worktree.id === activeWorktreeId) { + return worktree + } + } + } + return null + }, [activeWorktreeId, layout]) const visibleAgentsByPaneKey = useMemo(() => { const agents = new Map() for (const project of layout.projects) { @@ -125,7 +150,8 @@ export const AgentMapScene = memo(function AgentMapScene({ return ( ))} - {project.worktrees.map((worktree) => ( - - ))} + {project.worktrees.map((worktree) => + worktree.id === activeWorktreeId ? null : ( + + ) + )} ) })} + {/* Drawn last so a hovered name clears every other project's rings, and + * outside the declutter pass so it reads at any zoom. */} + {activeWorktree ? ( + + + + ) : null} ) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx new file mode 100644 index 00000000000..8803b3336a5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +describe('AgentMap status glow', () => { + installAgentMapEnvironment() + + it.each([ + { bucket: 'working', dotState: 'working', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, + { bucket: 'done', dotState: 'done', unseen: true, glows: false }, + { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } + ] as const)('applies the expected halo for $dotState agents', (state) => { + const { container } = renderMap([card(state)]) + const glow = container.querySelector('[data-agent-map-agent-status-glow]') + + if (state.glows) { + expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) + return + } + expect(glow).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx index 455b20df5bd..27a1c455341 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -17,7 +17,8 @@ export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ labelScale, mapScale }: AgentMapWorktreeLabelProps): React.JSX.Element { - const showCount = visible && (active || worktree.radius * mapScale >= 80) + // Hover is an explicit ask for this workspace's detail, so it outranks declutter. + const showCount = active || (visible && worktree.radius * mapScale >= 80) const agentCountText = translate( 'dashboardPopout.map.agentCount', worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 251fabbb079..ee49a574901 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -22,6 +22,8 @@ type AgentMapWorktreeRingNodeProps = { worktree: AgentMapWorktreeRing zoom: number mapScale: number + /** Pressed at the start of a pan drag; keeps the ring lit through the gesture. */ + held: boolean selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -33,7 +35,8 @@ type AgentMapWorktreeRingNodeProps = { event: React.MouseEvent, worktree: AgentMapWorktreeRing ) => void - onLabelActiveChange: (worktreeId: string, active: boolean) => void + onLabelHoverChange: (worktreeId: string, active: boolean) => void + onLabelFocusChange: (worktreeId: string, active: boolean) => void onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } @@ -175,6 +178,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ worktree, zoom, mapScale, + held, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -183,7 +187,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ onSelectAgent, onSpawnAgent, onOpenWorkspaceContextMenu, - onLabelActiveChange, + onLabelHoverChange, + onLabelFocusChange, onAgentKeyDown }: AgentMapWorktreeRingNodeProps): React.JSX.Element { const [detailsOpen, setDetailsOpen] = useState(false) @@ -196,14 +201,15 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ return ( onLabelActiveChange(worktree.id, true)} - onPointerLeave={() => onLabelActiveChange(worktree.id, false)} - onFocus={() => onLabelActiveChange(worktree.id, true)} + onPointerEnter={() => onLabelHoverChange(worktree.id, true)} + onPointerLeave={() => onLabelHoverChange(worktree.id, false)} + onFocus={() => onLabelFocusChange(worktree.id, true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - onLabelActiveChange(worktree.id, false) + onLabelFocusChange(worktree.id, false) } }} > diff --git a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts new file mode 100644 index 00000000000..ea5bbab362e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/agent-map.css'), + 'utf8' +) + +const PROJECT_SCALE = + /:where\(\s*\.agent-map-project-node:hover,\s*\.agent-map-project-node:focus-within,\s*\.agent-map-project-node\.is-held\s*\)\s*\.agent-map-project-ring\s*\{([^}]*)\}/ +const WORKTREE_SCALE = + /:where\(\s*\.agent-map-worktree-group:hover,\s*\.agent-map-worktree-group:focus-within,\s*\.agent-map-worktree-group\.is-held\s*\)\s*\.agent-map-worktree-ring\s*\{([^}]*)\}/ + +/** A ring that only reacts to :hover on itself pulses shut whenever the pointer + * crosses onto something drawn inside it, and again when a pan drag takes + * pointer capture. Both triggers have to live on the containing group. */ +describe('Agent Map hover containment', () => { + it('scales each ring from its containing group, never from the ring element', () => { + expect(css).not.toMatch(/\.agent-map-(?:project|worktree)-ring:hover/) + expect(css.match(PROJECT_SCALE)?.[1]).toContain('transform: scale') + expect(css.match(WORKTREE_SCALE)?.[1]).toContain('transform: scale') + }) + + it('keeps the group-scoped hover at ring specificity so state rules still win', () => { + // `:where()` contributes no specificity, so the workspace state rules keep + // overriding hover fill and stroke — but only while they stay below it. + const hoverAt = css.search(WORKTREE_SCALE) + + expect(hoverAt).toBeGreaterThan(css.indexOf('.agent-map-worktree-ring {')) + for (const state of ['.is-open', '.is-selected', '.is-working', '.is-blocked']) { + expect(css.indexOf(`.agent-map-worktree-ring${state}`)).toBeGreaterThan(hoverAt) + } + }) + + it('expands containing rings for keyboard focus as well as pointer hover', () => { + expect(css.match(PROJECT_SCALE)?.[0]).toContain('.agent-map-project-node:focus-within') + expect(css.match(WORKTREE_SCALE)?.[0]).toContain('.agent-map-worktree-group:focus-within') + }) + + it('drops both hover triggers under reduced motion', () => { + const reducedMotion = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) + + expect(reducedMotion).toContain('.agent-map-project-node.is-held') + expect(reducedMotion).toContain('.agent-map-worktree-group.is-held') + expect(reducedMotion).toMatch(/\.agent-map-project-node:hover/) + expect(reducedMotion).toMatch(/\.agent-map-worktree-group:hover/) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts index 28c71c881a1..ad02fb56243 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -90,19 +90,6 @@ describe('selectVisibleAgentMapLabels', () => { const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) expect(labels.worktreeIds.size).toBe(0) - expect(labels.worktreeAgentSafeIds.size).toBe(0) - }) - - it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { - const layout = layoutOf([ - worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), - worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) - ]) - - const labels = selectVisibleAgentMapLabels(layout, 1, 1) - - expect([...labels.worktreeIds]).toEqual(['busy']) - expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) }) it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 58a1f53c4d6..841197437e6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -38,8 +38,6 @@ type LabelGrid = Map> export type AgentMapVisibleLabels = { /** Worktree ring ids whose name can render without colliding. */ worktreeIds: Set - /** Worktree names that hover/focus may reveal without covering an agent. */ - worktreeAgentSafeIds: Set /** Project ids whose agent/workspace count line still has room. The count is * the first thing dropped: it repeats what the filter rail already says. */ projectCountIds: Set @@ -221,7 +219,6 @@ export function selectVisibleAgentMapLabels( }) const worktreeIds = new Set() - const worktreeAgentSafeIds = new Set() for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { const box = baselineBox( worktree.x, @@ -234,7 +231,6 @@ export function selectVisibleAgentMapLabels( if (collides(agentGrid, box)) { continue } - worktreeAgentSafeIds.add(worktree.id) if (collides(grid, box)) { continue } @@ -261,5 +257,5 @@ export function selectVisibleAgentMapLabels( projectCountIds.add(project.id) } - return { worktreeIds, worktreeAgentSafeIds, projectCountIds } + return { worktreeIds, projectCountIds } } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx new file mode 100644 index 00000000000..45c83a33c8e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -0,0 +1,133 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import type { AgentMapState } from './agent-map-filter' + +export const NOW = 2_000_000_000 + +export function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +export type RenderMapOptions = { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + showOrchestrationLinks, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: RenderMapOptions = {} +): ReturnType { + return render( + + ) +} + +export type AgentMapTestEnvironment = { + /** Exposed so tests can assert the canvas does not re-read layout when idle. */ + boundsSpy: ReturnType +} + +const CANVAS_BOUNDS = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) +} +const ZERO_BOUNDS = { ...CANVAS_BOUNDS, right: 0, bottom: 0, width: 0, height: 0 } + +/** Gives the map a measurable canvas and a non-Mac platform, the way every map + * suite needs it. Call once per describe block. */ +export function installAgentMapEnvironment(): AgentMapTestEnvironment { + const environment = {} as AgentMapTestEnvironment + const originalUserAgent = navigator.userAgent + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + environment.boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + return this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement + ? CANVAS_BOUNDS + : ZERO_BOUNDS + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + environment.boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + return environment +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 6107332ba0d..fb1f392f707 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -31,7 +31,13 @@ vector-effect: non-scaling-stroke; } -.agent-map-project-ring:hover { +/* Group hover includes nested contents; held state bridges pointer capture. */ +:where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring { fill: color-mix(in srgb, var(--card) 42%, transparent); stroke: var(--ring); transform: scale(1.018); @@ -154,7 +160,12 @@ stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); } -.agent-map-worktree-ring:hover { +:where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring { fill: color-mix(in srgb, var(--card) 72%, transparent); stroke: var(--ring); transform: scale(1.035); @@ -203,6 +214,11 @@ text-anchor: middle; } +.agent-map-worktree-label-layer, +.agent-map-worktree-hover-label-layer { + pointer-events: none; +} + .agent-map-worktree-label-group { opacity: 0; pointer-events: none; @@ -480,8 +496,18 @@ transition: none; } - .agent-map-project-ring:hover, - .agent-map-worktree-ring:hover, + :where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring, + :where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring, .agent-map-agent-node:hover .agent-map-agent-visual, .agent-map-agent-node:focus-visible .agent-map-agent-visual { transform: none; diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts new file mode 100644 index 00000000000..924c18d6586 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts @@ -0,0 +1,47 @@ +import { useCallback, useState, type RefObject } from 'react' + +export type AgentMapPointerHold = { + projectId: string | null + worktreeId: string | null +} + +type AgentMapPointerDragRef = RefObject<{ pointerId: number } | null> + +function closestId(target: Element, attribute: string): string | null { + return target.closest(`[${attribute}]`)?.getAttribute(attribute) ?? null +} + +/** + * Remembers which rings a pan drag started in. Pointer capture retargets + * `:hover` to the `` for the whole gesture, so the ring under the pointer + * would otherwise collapse until the gesture ends. + */ +export function useAgentMapPointerHold(dragRef: AgentMapPointerDragRef): { + held: AgentMapPointerHold | null + hold: (target: Element) => void + release: () => void + clearDrag: (pointerId: number) => boolean +} { + const [held, setHeld] = useState(null) + const hold = useCallback((target: Element): void => { + const projectId = closestId(target, 'data-agent-map-project-id') + const worktreeId = closestId(target, 'data-agent-map-worktree-id') + // A pan off empty canvas holds nothing, so leave the memoized scene alone. + setHeld(projectId === null && worktreeId === null ? null : { projectId, worktreeId }) + }, []) + const release = useCallback((): void => { + setHeld((current) => (current === null ? current : null)) + }, []) + const clearDrag = useCallback( + (pointerId: number): boolean => { + if (dragRef.current?.pointerId !== pointerId) { + return false + } + dragRef.current = null + release() + return true + }, + [dragRef, release] + ) + return { held, hold, release, clearDrag } +} From c065a2d7e107f619b3459ffcbf8d5ed5837b6d6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:23:40 -0700 Subject: [PATCH 002/114] Preserve AskUserQuestion waits during parallel tool completions (#13714) * fix(agent-hooks): preserve parallel question waits * fix(agent-hooks): preserve child question waits --- src/shared/agent-hook-listener.test.ts | 106 +++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 47 ++++++++--- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a03435d0e1f..298e7a55c79 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -391,6 +391,90 @@ describe('shared agent-hook-listener', () => { expect(next?.payload.interactivePrompt).toBeUndefined() }) + it('keeps AskUserQuestion visible through a late parallel sibling completion', () => { + const questions = { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: questions + } + }, + 'production' + ) + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'AskUserQuestion', + tool_input: questions + } + }, + 'production' + ) + const siblingCompletion = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_use_id: 'tool-sibling', + tool_input: { command: 'sleep 5' } + } + }, + 'production' + ) + + expect(siblingCompletion?.payload).toMatchObject({ + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: question?.payload.interactivePrompt + }) + }) + + it('clears AskUserQuestion when its own PostToolUse arrives', () => { + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question' + } + }, + 'production' + ) + + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => { // The question was answered, so PostToolUse must clear the live card instead // of re-deriving the `{questions}` prompt from the carried tool input. @@ -3594,6 +3678,28 @@ describe('shared agent-hook-listener', () => { expect(childTool?.payload.toolName).toBe('AskUserQuestion') }) + it('keeps a child AskUserQuestion visible through its parallel sibling completion', () => { + const question = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'a1', + tool_use_id: 'question-1', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + }) + + const siblingCompletion = claudeEvent({ + hook_event_name: 'PostToolUse', + agent_id: 'a1', + tool_use_id: 'sibling-1', + tool_name: 'Bash', + tool_input: { command: 'sleep 5' } + }) + + expect(siblingCompletion?.payload.state).toBe('waiting') + expect(siblingCompletion?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt) + expect(siblingCompletion?.payload.toolName).toBe('AskUserQuestion') + }) + it('preserves the interrupted flag across a gated working window', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c41bcb7354d..44032c03b24 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -150,6 +150,8 @@ export type ClaudeLeadTurnState = { interrupted?: true /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick } @@ -2620,7 +2622,7 @@ function normalizeClaudeSubagentLifecycleEvent( clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2712,8 +2714,8 @@ export function clearClaudeAnsweredQuestionWait( return effectiveState === restored.state ? restored : { state: effectiveState } } -/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */ -function buildClaudeChildDrivenStatusPayload( +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +function buildClaudeCachedLeadStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, @@ -2794,10 +2796,13 @@ function normalizeClaudeEvent( return null } - // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. - // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const isAskUserQuestion = - eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = @@ -2836,6 +2841,20 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = reportedStateName === 'waiting' @@ -2857,7 +2876,15 @@ function normalizeClaudeEvent( if (subagentOriginId) { const lead = state.claudeLeadStateByPaneKey.get(paneKey) if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. @@ -2872,7 +2899,7 @@ function normalizeClaudeEvent( // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. if (eventAgentId && !isWaitingInducing) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } if (isTurnBoundary && eventAgentId === undefined) { @@ -2897,10 +2924,12 @@ function normalizeClaudeEvent( ...(previousLead.interrupted ? { interrupted: true as const } : {}) } : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId state.claudeLeadStateByPaneKey.set(paneKey, { state: reportedStateName, ...(interrupted ? { interrupted } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}) }) From c7850eb59c5ad90f85faee8036589efa00dfbcb2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:49:34 -0700 Subject: [PATCH 003/114] fix(terminals): prevent retired workers from cold-resuming (#13708) --- config/reliability-gates.jsonc | 30 +- .../terminal-pane/pty-connection.test.ts | 10 +- .../terminal/terminal-tab-actions.test.ts | 51 ++ .../terminal/terminal-tab-actions.ts | 13 + ...completed-worker-retirement-resume.spec.ts | 521 +++++++++++++++ ...eted-worker-retirement-resume.unit.test.ts | 610 ++++++++++++++++++ .../completed-worker-retirement-fixture.ts | 194 ++++++ 7 files changed, 1424 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/completed-worker-retirement-resume.spec.ts create mode 100644 tests/e2e/completed-worker-retirement-resume.unit.test.ts create mode 100644 tests/e2e/helpers/completed-worker-retirement-fixture.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 16e479b2666..f5e5a39caeb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1969,7 +1969,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -1989,11 +1989,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2711,11 +2718,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2727,6 +2736,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -7867,16 +7883,24 @@ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" ], "testFiles": [ "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 9391b6d614a..2ff73043cc3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11599,11 +11599,12 @@ describe('connectPanePty', () => { expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => { + it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-1') transportFactoryQueue.push(transport) const pane = createPane(1) + const providerSession = { key: 'session_id', id: 'codex-session-1' } as const connectPanePty( pane as never, @@ -11611,6 +11612,7 @@ describe('connectPanePty', () => { createDeps({ startup: { command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession, showSessionRestoredBanner: true } }) as never @@ -11626,7 +11628,11 @@ describe('connectPanePty', () => { expect.stringContaining('--- session restored ---'), expect.any(Function) ) - expect(createdTransportOptions[0]?.command).toBe("codex 'resume' 'codex-session-1'") + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(createdTransportOptions[0]).toMatchObject({ + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession + }) }) it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a8747ec81ff..4b7557ba057 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -183,6 +183,57 @@ describe('closeTerminalTab', () => { isWebTerminalSurfaceTabIdMock.mockReturnValue(false) }) + it('retires exact resume authority when explicit cleanup arrives after the tab disappeared', () => { + const closeTab = vi.fn() + const onClosed = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab', { + hostCloseReason: 'cleanup', + localPtyTeardownOwnedExternally: true, + onClosed + }) + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { + reason: 'cleanup', + localPtyTeardownOwnedExternally: true + }) + expect(onClosed).toHaveBeenCalledOnce() + }) + + it('retires exact resume authority when a user close arrives after the tab disappeared', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab') + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { reason: 'user' }) + }) + + it.each([ + ['local PTY exit', { reason: 'pty-exit' as const }], + ['paired host PTY exit', { hostCloseReason: 'pty-exit' as const }] + ])('preserves missing-tab recovery authority for %s', (_label, options) => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('unexpectedly-lost-tab', options) + + expect(closeTab).not.toHaveBeenCalled() + }) + it('delegates host-backed terminal closes to the paired runtime', () => { const closeTab = vi.fn() isWebRuntimeSessionActiveMock.mockReturnValue(true) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 55934e7ae88..8216696d217 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -65,6 +65,19 @@ export function closeTerminalTab( ) const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState) if (!target) { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + if (closeReason !== 'pty-exit') { + // Why: late explicit cleanup must still revoke tab-scoped resume authority after PTY exit removed the row. + state.closeTab(tabId, { + reason: closeReason, + ...(options?.localPtyTeardownOwnedExternally + ? { localPtyTeardownOwnedExternally: true } + : {}), + ...(options?.precomputedRetirementPlan + ? { precomputedRetirementPlan: options.precomputedRetirementPlan } + : {}) + }) + } options?.onClosed?.() return } diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts new file mode 100644 index 00000000000..5540c4d6896 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -0,0 +1,521 @@ +import { test as base, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerRecoveryRecord, + runBuiltOrcaCli, + seedCurrentCodexTranscript, + terminalIdentity +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree-id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' + +const test = base.extend({ + launchEnv: [completedWorkerLaunchEnv, { option: true }] +}) + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) { + test(`completed background worker ${closeMode} retires resume authority before first activation`, async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + clearCompletedWorkerLedger() + await waitForSessionReady(orcaPage) + const coordinatorWorktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }) + + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const isolatedHome = await electronApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const coordinatorResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: coordinatorPane.paneKey } + ) + const coordinatorHandle = coordinatorResolved.result.terminal.handle + const coordinator = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + if (!coordinator) { + throw new Error('Coordinator terminal was not runtime-visible') + } + const coordinatorBefore = terminalIdentity(coordinator) + + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + const rendererWorktreeIds = await orcaPage.evaluate(() => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .map((worktree) => worktree.id) + ) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Retire one completed background worker', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion, then exit normally', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${String(targetWorktreeId)}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId || !worker.incarnationId) { + throw new Error('Background worker did not publish exact PTY identity') + } + const workerBefore = terminalIdentity(worker) + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + expect(worker.worktreeId).toBe(targetWorktreeId) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId: worker.tabId, worktreeId: targetWorktreeId } + ) + await expect + .poll(() => + orcaPage.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerBefore.tabId) + ) + .toBe(true) + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'spawn')) + .toHaveLength(1) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + + await orcaPage.evaluate( + ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const providerSession = { + key: 'session_id' as const, + id: providerSessionId, + transcriptPath + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession, + launchConfig: { + agentCommand: 'codex', + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + state.setAgentStatus( + paneKey, + { state: 'done', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + }, + { + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: worker.tabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + + const expectedRecovery = { + origin: 'live', + state: 'working', + providerSessionId: PROVIDER_SESSION_ID + } + await expect + .poll(() => + orcaPage.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession.id + } + : null + }, workerPaneKey) + ) + .toEqual(expectedRecovery) + await expect + .poll( + () => { + const record = readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey) + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession?.id + } + : null + }, + { timeout: 30_000 } + ) + .toEqual(expectedRecovery) + + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed. It found no work. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + await expect + .poll( + async () => { + const dispatch = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + const tasks = await client.call<{ tasks: { id: string; status: string }[] }>( + 'orchestration.taskList', + { run: run.result.run.id } + ) + return { + dispatch: dispatch.result.dispatch?.status ?? null, + task: + tasks.result.tasks.find((candidate) => candidate.id === task.result.task.id) + ?.status ?? null + } + }, + { timeout: 30_000, message: 'worker completion never settled its task and dispatch' } + ) + .toEqual({ dispatch: 'completed', task: 'completed' }) + + await client.call('terminal.send', { + terminal: workerHandle, + text: 'ORCA_E2E_EXIT_AFTER_DONE', + enter: true + }) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')) + .toHaveLength(1) + expect( + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const state = window.__store?.getState() + return { + tabPresent: Boolean(state?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state?.sleepingAgentSessionsByPaneKey[paneKey]) + } + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + ).toEqual({ tabPresent: true, recoveryPresent: true }) + + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + const e2eWindow = window as typeof window & { + __orcaRetiredWorkerTransitions?: Transition[] + __orcaRetiredWorkerUnsubscribe?: () => void + } + const transitions: Transition[] = [ + { + tabPresent: Boolean( + store.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + recoveryPresent: Boolean(store.getState().sleepingAgentSessionsByPaneKey[paneKey]) + } + ] + e2eWindow.__orcaRetiredWorkerTransitions = transitions + e2eWindow.__orcaRetiredWorkerUnsubscribe = store.subscribe((state) => { + const next = { + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state.sleepingAgentSessionsByPaneKey[paneKey]) + } + const previous = transitions.at(-1) + if ( + !previous || + previous.tabPresent !== next.tabPresent || + previous.recoveryPresent !== next.recoveryPresent + ) { + transitions.push(next) + } + }) + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + + if (closeMode === 'terminal-close-cli') { + const closed = runBuiltOrcaCli(['terminal', 'close', '--terminal', workerHandle, '--json'], { + userDataDir, + cwd: process.cwd() + }) + expect(closed).toMatchObject({ + ok: true, + result: { + close: { + handle: workerHandle, + tabId: workerBefore.tabId, + ptyKilled: true + } + } + }) + } else { + const release = await client.call<{ + dispatchId: string + state: string + processAction: string + }>('orchestration.workerRelease', { dispatch: started.result.dispatchId }) + expect(release.result).toMatchObject({ + dispatchId: started.result.dispatchId, + state: 'released', + processAction: 'closed_agent_terminal' + }) + } + await expect + .poll(() => + orcaPage.evaluate(() => { + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + return (window as typeof window & { __orcaRetiredWorkerTransitions?: Transition[] }) + .__orcaRetiredWorkerTransitions + }) + ) + .toEqual( + expect.arrayContaining([ + { tabPresent: true, recoveryPresent: true }, + { tabPresent: false, recoveryPresent: false } + ]) + ) + await orcaPage.evaluate(() => { + const e2eWindow = window as typeof window & { __orcaRetiredWorkerUnsubscribe?: () => void } + e2eWindow.__orcaRetiredWorkerUnsubscribe?.() + delete e2eWindow.__orcaRetiredWorkerUnsubscribe + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] ?? null, + workerPaneKey + ) + ) + .toBeNull() + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll(() => + orcaPage.evaluate(async (paneKey) => { + const session = await window.api.session.get() + return session.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null + }, workerPaneKey) + ) + .toBeNull() + await orcaPage.evaluate(() => window.api.session.flush()) + expect(readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey)).toBeNull() + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + + const beforeActivation = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + tabCount: state?.tabsByWorktree[worktreeId]?.length ?? 0, + pendingStartupCount: Object.keys(state?.pendingStartupByTabId ?? {}).length + } + }, targetWorktreeId) + expect(beforeActivation).toEqual({ everActivated: false, tabCount: 0, pendingStartupCount: 0 }) + + const targetCard = orcaPage + .locator(`[data-worktree-id="${String(targetWorktreeId)}"]`) + .first() + .locator('[data-worktree-card-surface]') + await targetCard.evaluate((element: HTMLElement) => element.click()) + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId)) + .toBe(targetWorktreeId) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const activatedPane = await waitForActivePaneHookDescriptor(orcaPage) + expect(activatedPane.worktreeId).toBe(targetWorktreeId) + const activatedResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: activatedPane.paneKey } + ) + await expect + .poll( + async () => { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: activatedResolved.result.terminal.handle, + limit: 50 + }) + return read.result.terminal.tail.join('\n') + }, + { timeout: 30_000, message: 'activated fallback terminal never produced output' } + ) + .not.toBe('') + + const spawnEvents = readCompletedWorkerLedger().filter((event) => event.event === 'spawn') + expect(spawnEvents).toHaveLength(1) + expect( + spawnEvents.filter( + (event) => event.args?.includes('resume') && event.args?.includes(PROVIDER_SESSION_ID) + ) + ).toEqual([]) + await expect(orcaPage.locator('.session-restored-banner')).toHaveCount(0) + + const afterActivation = await orcaPage.evaluate( + ({ originalTabId, worktreeId }) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[worktreeId] ?? [] + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + originalTabPresent: tabs.some((tab) => tab.id === originalTabId), + replacementTabCount: tabs.filter((tab) => tab.id !== originalTabId).length, + pendingResumeSessionIds: Object.values(state?.pendingStartupByTabId ?? {}).flatMap( + (startup) => (startup.resumeProviderSession ? [startup.resumeProviderSession.id] : []) + ), + resumeClaimCount: Object.keys(state?.automaticAgentResumeClaimsByTabId ?? {}).length + } + }, + { originalTabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + expect(afterActivation).toEqual({ + everActivated: true, + originalTabPresent: false, + replacementTabCount: 1, + pendingResumeSessionIds: [], + resumeClaimCount: 0 + }) + + const coordinatorAfter = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + expect(coordinatorAfter ? terminalIdentity(coordinatorAfter) : null).toEqual(coordinatorBefore) + expect(coordinatorAfter?.worktreeId).toBe(coordinatorWorktreeId) + }) +} diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts new file mode 100644 index 00000000000..03292dfb0e6 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -0,0 +1,610 @@ +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell' +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import type { TerminalTab, Worktree } from '../../src/shared/types' +import { OrchestrationDb } from '../../src/main/runtime/orchestration/db' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { RpcContext } from '../../src/main/runtime/rpc/core' +import { ORCHESTRATION_METHODS } from '../../src/main/runtime/rpc/methods/orchestration' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { seedStartupSessionRestoredBanner } from '@/components/terminal-pane/session-restored-banner-pane-state' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '@/hooks/legacy-worker-terminal-recovery-event' +import { useAppStore, type AppState } from '@/store' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' +const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3' +const ORIGINAL_LEAF_ID = '0526f763-6729-49af-adf8-85ddbcf2b4e7' +const ORIGINAL_PANE_KEY = makePaneKey(ORIGINAL_TAB_ID, ORIGINAL_LEAF_ID) +const ORIGINAL_PTY_ID = 'pty-background-worker' +const REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WORKTREE_PATH = path.join(path.sep, 'workspace', 'factory-pr-4626-git-crypt') +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const CANARY_WORKTREE_PATH = path.join(path.sep, 'workspace', 'canary') +const CANARY_WORKTREE_ID = `${REPO_ID}::${CANARY_WORKTREE_PATH}` +const CANARY_TAB_ID = 'canary-tab' +const CANARY_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const CANARY_PTY_ID = 'pty-unrelated-canary' +const HELPER_TAB_ID = 'worker-child-terminal' +const HELPER_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const HELPER_PANE_KEY = makePaneKey(HELPER_TAB_ID, HELPER_LEAF_ID) +const TERMINAL_HANDLE = 'terminal-background-worker' +const initialAppStoreState = useAppStore.getState() + +function makeWorktree(id: string, workspacePath: string): Worktree { + return { + id, + repoId: REPO_ID, + path: workspacePath, + head: 'abc123', + branch: 'refs/heads/review', + isBare: false, + isMainWorktree: false, + displayName: path.basename(workspacePath), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'local' + } +} + +function makeTab(id: string, worktreeId: string, ptyId: string, title: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId: string, ptyId: string) { + return { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function seedWorkspace(options: { helper?: boolean } = {}): void { + useAppStore.setState(initialAppStoreState, true) + const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH) + const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH) + const original = makeTab( + ORIGINAL_TAB_ID, + WORKTREE_ID, + ORIGINAL_PTY_ID, + 'PR 4626 unified correction r3' + ) + const unrelated = makeTab(CANARY_TAB_ID, CANARY_WORKTREE_ID, CANARY_PTY_ID, 'Unrelated') + const helper = makeTab(HELPER_TAB_ID, WORKTREE_ID, 'pty-worker-child', 'Worker child') + useAppStore.setState({ + repos: [ + { + id: REPO_ID, + path: path.join(path.sep, 'workspace'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' + } + ], + worktreesByRepo: { [REPO_ID]: [target, canary] }, + activeRepoId: REPO_ID, + activeWorktreeId: CANARY_WORKTREE_ID, + activeTabId: CANARY_TAB_ID, + activeTabType: 'terminal', + activeView: 'terminal', + tabsByWorktree: { + [WORKTREE_ID]: options.helper ? [original, helper] : [original], + [CANARY_WORKTREE_ID]: [unrelated] + }, + ptyIdsByTabId: { + [ORIGINAL_TAB_ID]: [ORIGINAL_PTY_ID], + [CANARY_TAB_ID]: [CANARY_PTY_ID], + ...(options.helper ? { [HELPER_TAB_ID]: ['pty-worker-child'] } : {}) + }, + terminalLayoutsByTabId: { + [ORIGINAL_TAB_ID]: makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID), + [CANARY_TAB_ID]: makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID), + ...(options.helper ? { [HELPER_TAB_ID]: makeLayout(HELPER_LEAF_ID, 'pty-worker-child') } : {}) + }, + activeTabIdByWorktree: { + [WORKTREE_ID]: ORIGINAL_TAB_ID, + [CANARY_WORKTREE_ID]: CANARY_TAB_ID + }, + activeTabTypeByWorktree: { + [WORKTREE_ID]: 'terminal', + [CANARY_WORKTREE_ID]: 'terminal' + }, + tabBarOrderByWorktree: { + [WORKTREE_ID]: options.helper ? [ORIGINAL_TAB_ID, HELPER_TAB_ID] : [ORIGINAL_TAB_ID], + [CANARY_WORKTREE_ID]: [CANARY_TAB_ID] + }, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + everActivatedWorktreeIds: new Set([CANARY_WORKTREE_ID]), + settings: { + ...initialAppStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' }, + setupScriptLaunchMode: 'new-tab' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as Partial) +} + +function recordWorkingWorker() { + const providerSession = { key: 'session_id' as const, id: PROVIDER_SESSION_ID } + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'working', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_478_130, stateStartedAt: 1_786_361_478_130 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + return providerSession +} + +function completeRecordedWorker( + providerSession: ReturnType +): SleepingAgentSessionRecord { + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'done', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_625_666, stateStartedAt: 1_786_361_625_666 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + expect(useAppStore.getState().agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'done', + providerSession + }) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + expect(record).toMatchObject({ + paneKey: ORIGINAL_PANE_KEY, + tabId: ORIGINAL_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession, + state: 'working', + origin: 'live' + }) + return record! +} + +function recordCompletedWorker(): SleepingAgentSessionRecord { + return completeRecordedWorker(recordWorkingWorker()) +} + +function expectCanaryUnchanged(): void { + const state = useAppStore.getState() + expect(state.tabsByWorktree[CANARY_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(state.terminalLayoutsByTabId[CANARY_TAB_ID]).toEqual( + makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID) + ) +} + +function orchestrationMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Missing orchestration method: ${name}`) + } + return method +} + +async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Promise { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const coordinatorPaneKey = 'coordinator-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const run = db.createRun({ + objective: 'Completed worker retirement reproduction', + coordinatorHandle: 'terminal-coordinator', + coordinatorPaneKey + }) + const ctx: RpcContext = { runtime } + const call = async (name: string, params: Record) => { + const method = orchestrationMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'terminal-coordinator' ? coordinatorPaneKey : ORIGINAL_PANE_KEY + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === TERMINAL_HANDLE ? 'runtime:test:worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === TERMINAL_HANDLE + ? ({ + terminalHandle: TERMINAL_HANDLE, + paneKey: ORIGINAL_PANE_KEY, + processIncarnation: 'runtime:test:worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: WORKTREE_ID } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + title: 'PR 4626 unified correction r3' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: TERMINAL_HANDLE, + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + ...(terminalState === 'exited' ? { connected: false } : { status: 'running' }) + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + status: terminalState, + tail: terminalState === 'exited' ? [] : ['completed worker output'], + truncated: false, + nextCursor: terminalState === 'exited' ? null : '1' + }) + const closeTerminal = vi.spyOn(runtime, 'closeTerminal').mockImplementation(async () => { + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + return { handle: TERMINAL_HANDLE, tabId: ORIGINAL_TAB_ID, ptyKilled: true } + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + try { + const task = db.createTask({ spec: 'release completed worker', runId: run.id }) + const started = (await call('orchestration.workerStart', { + task: task.id, + from: 'terminal-coordinator', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(started.state).toBe('ready') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('ready') + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatchId, + outcome: 'succeeded', + result: 'worker completed' + }).action + ).toBe('settled') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + + await expect( + call('orchestration.workerRelease', { dispatch: started.dispatchId }) + ).resolves.toMatchObject({ + dispatchId: started.dispatchId, + state: 'released', + processAction: terminalState === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal' + }) + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + expect(db.getWorkerTerminalResourceByOwner(started.dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released', + pane_key: ORIGINAL_PANE_KEY, + terminal_handle: TERMINAL_HANDLE + }) + expect(closeTerminal).toHaveBeenCalledOnce() + } finally { + db.close() + } +} + +function persistAndParseCurrentSession() { + const payload = buildWorkspaceSessionPayload(useAppStore.getState()) + const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(payload))) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.value +} + +async function hydrateSession( + session: ReturnType +): Promise { + seedWorkspace() + useAppStore.getState().hydrateWorkspaceSession(session) + useAppStore.getState().hydrateTabsSession(session) + await useAppStore.getState().reconnectPersistedTerminals() +} + +beforeEach(() => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtime: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } + } + }) + seedWorkspace() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('completed background-worker retirement resume matrix', () => { + it('does not cold-resume an explicitly completed and retired worker on first activation', async () => { + // Case 1: task completion alone keeps the still-owned provider session recoverable in place. + const ownedRecord = recordCompletedWorker() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBe( + ownedRecord + ) + + // Case 2: the renderer boundary used by an explicit Orca close retires the exact authority. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 3: both running and PTY-exit-first release retire exact resume authority. + seedWorkspace() + recordCompletedWorker() + await releaseCompletedWorker('running') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + seedWorkspace() + recordCompletedWorker() + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + origin: 'live', + state: 'working', + providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } + }) + await releaseCompletedWorker('exited') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + + const retiredRestart = persistAndParseCurrentSession() + expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined() + + // Case 4: legacy rollback preserves a fenced record; exited resolution clears it. + seedWorkspace() + const legacyRecord = recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [ORIGINAL_PANE_KEY]: { + ...legacyRecord, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + }) + const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'rolled_back', + ptyId: ORIGINAL_PTY_ID + }) + expect(legacyAction.kind).toBe('rollback-surface') + if (legacyAction.kind === 'rollback-surface') { + expect( + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail) + ).toBe('removed') + } + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect( + useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + ?.automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'exited' + }) + expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY }) + useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY) + + // Case 5: coordinator manual close is the same safe exact-tab retirement boundary. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 6: normal Codex exit leaves the shell pane; helper close retires only the helper. + seedWorkspace({ helper: true }) + recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + ...useAppStore.getState().sleepingAgentSessionsByPaneKey, + [HELPER_PANE_KEY]: { + paneKey: HELPER_PANE_KEY, + tabId: HELPER_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'unrelated-helper-session' }, + prompt: 'helper', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toEqual( + makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID) + ) + closeTerminalTab(HELPER_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[HELPER_PANE_KEY]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeDefined() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expectCanaryUnchanged() + + // Case 7: restart preserves owned panes, while post-retirement restart keeps authority absent. + seedWorkspace() + const workingProviderSession = recordWorkingWorker() + const beforeCompletion = persistAndParseCurrentSession() + expect(beforeCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(beforeCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'working', + origin: 'live', + providerSession: workingProviderSession + }) + await hydrateSession(beforeCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe(ORIGINAL_PTY_ID) + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toEqual([ORIGINAL_PTY_ID]) + completeRecordedWorker(recordWorkingWorker()) + const afterCompletion = persistAndParseCurrentSession() + expect(afterCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(afterCompletion.terminalLayoutsByTabId[ORIGINAL_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [ORIGINAL_LEAF_ID]: ORIGINAL_PTY_ID + }) + expect(afterCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeDefined() + await hydrateSession(afterCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[CANARY_WORKTREE_ID]?.[0]?.id).toBe(CANARY_TAB_ID) + + activateAndRevealWorktree(CANARY_WORKTREE_ID, { notifyHostRuntime: false }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + await releaseCompletedWorker('exited') + const restartAfterRetirement = persistAndParseCurrentSession() + await hydrateSession(restartAfterRetirement) + + // Case 8: first activation of the never-visited target cannot resurrect retired authority. + const beforeActivation = useAppStore.getState() + expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false) + expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(beforeActivation.sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(Object.keys(beforeActivation.pendingStartupByTabId)).toEqual([]) + const tabCountBeforeActivation = beforeActivation.tabsByWorktree[WORKTREE_ID]?.length ?? 0 + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + const activated = useAppStore.getState() + const replacementTabs = (activated.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== ORIGINAL_TAB_ID + ) + // The provider-ownership gate separately proves this request becomes one transport spawn. + const coldSpawnRequests = replacementTabs.flatMap((tab) => { + const startup = activated.pendingStartupByTabId[tab.id] + if (!startup?.resumeProviderSession) { + return [] + } + const tokens = tokenizeStartupCommand(startup.command, 'posix') + expect(tokens.ok).toBe(true) + const showSessionRestoredBanner = vi.fn() + seedStartupSessionRestoredBanner(startup, 1, showSessionRestoredBanner) + return [ + { + providerSession: startup.resumeProviderSession, + command: startup.command, + argv: tokens.ok ? tokens.tokens : [], + restoredBannerCount: showSessionRestoredBanner.mock.calls.length + } + ] + }) + + expect(tabCountBeforeActivation).toBe(0) + expect(replacementTabs).toHaveLength(1) + expect(activated.tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === ORIGINAL_TAB_ID)).toBe( + false + ) + expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(coldSpawnRequests).toEqual([]) + expectCanaryUnchanged() + expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0) + expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + + // Required invariant: explicit completion plus retirement must revoke provider-resume authority. + expect(coldSpawnRequests).toEqual([]) + }) +}) diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts new file mode 100644 index 00000000000..d1211234258 --- /dev/null +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -0,0 +1,194 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { RuntimeClient } from '../../../src/cli/runtime-client' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import type { + RuntimeTerminalListResult, + RuntimeTerminalSummary +} from '../../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-retired-worker-')) +const lifecycleLedgerPath = path.join(fakeCliDir, 'codex-lifecycle.jsonl') +const fakeCodexSource = ` +const { appendFileSync } = require('node:fs') +const ledger = process.env.ORCA_E2E_CODEX_LIFECYCLE_LEDGER +const append = (event) => appendFileSync(ledger, JSON.stringify({ pid: process.pid, ...event }) + '\\n') +const args = process.argv.slice(2) +if (args.includes('app-server')) { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +append({ event: 'spawn', args }) +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +process.stdin.on('data', (chunk) => { + const input = chunk.toString() + append({ event: 'input', input }) + if (input.includes('ORCA_E2E_EXIT_AFTER_DONE')) { + append({ event: 'normal-exit' }) + process.exit(0) + } + if (input.includes('\\r')) process.stdout.write('ACK\\n') +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +export const completedWorkerLaunchEnv = { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath +} + +export type LifecycleEvent = { + pid: number + event: 'spawn' | 'input' | 'normal-exit' + args?: string[] + input?: string +} + +export type TerminalIdentity = Pick< + RuntimeTerminalSummary, + 'handle' | 'incarnationId' | 'leafId' | 'ptyId' | 'tabId' | 'worktreeId' +> + +export function clearCompletedWorkerLedger(): void { + rmSync(lifecycleLedgerPath, { force: true }) +} + +export function cleanupCompletedWorkerFixture(): void { + rmSync(fakeCliDir, { recursive: true, force: true }) +} + +export function readCompletedWorkerLedger(): LifecycleEvent[] { + if (!existsSync(lifecycleLedgerPath)) { + return [] + } + const contents = readFileSync(lifecycleLedgerPath, 'utf8') + const lastCompleteLine = contents.lastIndexOf('\n') + if (lastCompleteLine < 0) { + return [] + } + return contents + .slice(0, lastCompleteLine) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as LifecycleEvent) +} + +export function readCompletedWorkerDispatchCapability(): string | null { + const input = readCompletedWorkerLedger() + .filter((event) => event.event === 'input') + .map((event) => event.input ?? '') + .join('') + return input.match(/--dispatch-capability\s+(\S+)/)?.[1] ?? null +} + +export function runBuiltOrcaCli( + args: string[], + options: { userDataDir: string; cwd: string } +): unknown { + const { + ORCA_ENVIRONMENT: _environment, + ORCA_PAIRING_CODE: _pairingCode, + ORCA_USER_DATA_PATH: _userDataPath, + ...cleanEnv + } = process.env + void _environment + void _pairingCode + void _userDataPath + const output = execFileSync( + process.execPath, + [path.join(process.cwd(), 'out', 'cli', 'index.js'), ...args], + { + cwd: options.cwd, + env: { ...cleanEnv, ORCA_USER_DATA_PATH: options.userDataDir }, + encoding: 'utf8', + timeout: 30_000 + } + ) + return JSON.parse(output) as unknown +} + +export function seedCurrentCodexTranscript( + isolatedHome: string, + providerSessionId: string, + cwd: string +): string { + const now = new Date() + const transcriptDir = path.join( + isolatedHome, + '.codex', + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ) + mkdirSync(transcriptDir, { recursive: true }) + const transcriptPath = path.join(transcriptDir, `rollout-${providerSessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: now.toISOString(), + type: 'session_meta', + payload: { id: providerSessionId, cwd } + })}\n` + ) + return transcriptPath +} + +export function terminalIdentity(terminal: RuntimeTerminalSummary): TerminalIdentity { + const { handle, incarnationId, leafId, ptyId, tabId, worktreeId } = terminal + return { handle, incarnationId, leafId, ptyId, tabId, worktreeId } +} + +export async function listRuntimeTerminals( + client: RuntimeClient +): Promise { + return (await client.call('terminal.list')).result.terminals +} + +export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return null + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + origin?: unknown + state?: unknown + providerSession?: { id?: unknown } + } + > + } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null +} From 8c975b9d15fda7990c65e044267ec157a48d3c2d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 10 Aug 2026 19:55:29 -0700 Subject: [PATCH 004/114] fix(terminal): avoid Windows PATH registry subprocesses (#13485) --- src/main/cli/cli-installer.test.ts | 2 +- .../cli/windows-user-path-registry.test.ts | 2 +- src/main/cli/windows-user-path-registry.ts | 41 ++----- ...eflight-windows-path-refresh.repro.test.ts | 65 ++--------- ...windows-environment-path-main-loop.test.ts | 80 +++++++++++++ src/main/pty/windows-environment-path.test.ts | 110 ++++++++---------- src/main/pty/windows-environment-path.ts | 86 +++++++++----- .../pty/windows-path-registry-reader.test.ts | 70 +++++++++++ src/main/pty/windows-path-registry-reader.ts | 67 +++++++++++ src/main/windows-native-registry.ts | 25 ++++ 10 files changed, 371 insertions(+), 177 deletions(-) create mode 100644 src/main/pty/windows-environment-path-main-loop.test.ts create mode 100644 src/main/pty/windows-path-registry-reader.test.ts create mode 100644 src/main/pty/windows-path-registry-reader.ts create mode 100644 src/main/windows-native-registry.ts diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..e1ace3350ca 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -18,8 +18,26 @@ import { readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +85,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +103,26 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +130,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +147,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +154,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +169,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +176,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +187,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +217,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +226,9 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..8fad453cab5 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,10 @@ -import { execFile, execFileSync } from 'node:child_process' +import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { readWindowsPathRegistry } from './windows-path-registry-reader' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,10 +17,7 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} +type RegistryPathRead = { failed: boolean; segments: string[] } const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], @@ -71,6 +69,19 @@ function registryOutputSegments( : [] } +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) +} + function keepLastGoodSegments(segments: string[], failedReads: number): string[] { if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { return [...persistedWindowsPathCache.segments] @@ -145,34 +156,39 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) + const failedReads = reads.filter((read) => read.failed).length if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. return cachePersistedWindowsPathSegments(segments, failedReads) } @@ -202,14 +218,22 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..634cd30e72a --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value as a failed query', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..6f98c45e390 --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,67 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const entry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + )?.[1] + if (!entry) { + return { failed: true, value: null } + } + if ( + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} From 7d1a17465e50d0bedf17d3fffa4de1cd85c6cbbe Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:56:08 -0700 Subject: [PATCH 005/114] perf(computer-use): skip unused accessibility summaries (#13559) --- config/scripts/verify-computer-native.mjs | 19 ++ native/computer-use-linux/runtime.py | 7 +- .../computer-use-linux/runtime_render_test.py | 198 ++++++++++++++++ .../runtime-render.test.ps1 | 224 ++++++++++++++++++ native/computer-use-windows/runtime.ps1 | 8 +- 5 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 native/computer-use-linux/runtime_render_test.py create mode 100644 native/computer-use-windows/runtime-render.test.ps1 diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..ba23a49cdd0 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { From 3e3f48fe895413ffaa4d90ae2ce5670e6c795012 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:03:48 -0700 Subject: [PATCH 006/114] fix(native-chat): restore Codex model picker dispatch (#13669) * fix(native-chat): restore Codex model picker dispatch * fix(native-chat): type Codex effort picker command --- .../session/mobile-native-chat-send.test.ts | 38 +++++++++++++++- mobile/src/session/mobile-native-chat-send.ts | 22 ++++++++++ ...se-mobile-native-chat-message-send.test.ts | 17 ++++++++ .../use-mobile-native-chat-message-send.ts | 40 +++++++++++++++-- ...mobile-native-chat-session-options.test.ts | 21 ++++++--- .../use-mobile-native-chat-session-options.ts | 10 ++++- .../native-chat/NativeChatComposer.test.tsx | 15 ++++--- .../native-chat-pty-session-options.test.ts | 2 +- .../native-chat-runtime-send.test.ts | 30 +++++++++++++ .../native-chat/native-chat-runtime-send.ts | 19 ++++++++ .../native-chat-session-option-apply.ts | 4 +- ...ve-chat-session-option-command-dispatch.ts | 6 ++- .../use-native-chat-session-option-command.ts | 22 +++++++--- ...ent-session-option-catalog-claude-codex.ts | 11 ++--- .../agent-session-option-catalog-types.ts | 3 +- src/shared/agent-session-option-catalog.ts | 1 + src/shared/agent-tui-command-typing.ts | 43 +++++++++++++++++++ ...ative-chat-session-option-commands.test.ts | 9 +++- ...ative-chat-session-option-snapshot.test.ts | 4 +- 19 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 src/shared/agent-tui-command-typing.ts diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..1c7a661cda5 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..e7cb0e45beb 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + return typeAgentTuiCommand({ + command: args.command, + write: (key) => + sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..6e92169b819 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + it('binds classification to the agent that started the send', async () => { let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void sendWithOutcome.mockReturnValue( diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 338c800cecd..e3bc6c0cabd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,10 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (options?.delivery === 'type') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9..90fa4380c77 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), draftScopeKeys: [] as string[], @@ -65,6 +66,7 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) @@ -181,6 +183,7 @@ describe('NativeChatComposer', () => { }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.typeNativeChatCommand.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, @@ -550,7 +553,7 @@ describe('NativeChatComposer', () => { expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) - it('applies a Codex model change without switching to the terminal', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mocks.sendHandle.settleAfterMs = 0 const onSwitchToTerminal = vi.fn() render( @@ -563,17 +566,17 @@ describe('NativeChatComposer', () => { /> ) - // Codex model changes are value-bearing commands; only effort still uses the TUI picker. await act(async () => { - await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5') + await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model') }) - expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( {}, 'pty-1', - '/model gpt-5.5', + '/model', expect.any(AbortSignal) ) - expect(onSwitchToTerminal).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled() + expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 0dd5e19025d..37850bccaec 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -565,7 +565,7 @@ describe('native chat PTY session options', () => { ) const result = await surface.invokeAction('effort') - expect(dispatch).toHaveBeenCalledWith('/model') + expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' }) expect(onAgentPicker).toHaveBeenCalledOnce() expect(result.snapshot).toHaveLength(1) expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 93a63765a46..d5e54f205fa 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { sendNativeChatMessage, sendNativeChatMessageVerified, + typeNativeChatCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -252,6 +253,35 @@ describe('sendNativeChatMessageVerified', () => { }) }) +describe('typeNativeChatCommand', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() + }) + + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe(true) + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 'm', + 'o', + 'd', + 'e', + 'l', + NATIVE_CHAT_SUBMIT + ]) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b..02bb53fbcdd 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -19,6 +19,7 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing' import { cancelNativeChatPtySends, enqueueNativeChatPtySend, @@ -213,6 +214,24 @@ export async function sendNativeChatMessageVerified( return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) } +/** Types a slash command as individual keys so Codex opens its command palette. */ +export async function typeNativeChatCommand( + settings: RuntimeSettings, + ptyId: string, + command: string, + signal?: AbortSignal +): Promise { + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + const outcome = await typeAgentTuiCommand({ + command, + signal, + write: async (key) => + (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + }) + return outcome === 'accepted' +} + export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts index 98481e1035c..5585512633a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts @@ -109,7 +109,9 @@ async function handleAgentPicker( ctx: SessionOptionApplyContext, midSession: Extract ): Promise { - await ctx.dispatchCommand(midSession.command) + await (midSession.delivery + ? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : ctx.dispatchCommand(midSession.command)) ctx.clearModelTruth() const snapshot = ctx.publish() ctx.onAgentPicker?.() diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts index c510990dc5e..d7b071c55e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -1,4 +1,7 @@ -import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { + CatalogAgentInteractionDetection, + CatalogCommandDelivery +} from '../../../../shared/agent-session-option-catalog' import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' export type NativeChatSessionOptionDispatchResult = { @@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = ( options?: { detectAgentInteraction?: CatalogAgentInteractionDetection expectedChoiceLabel?: string + delivery?: CatalogCommandDelivery } ) => | Promise diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 96d3cbdf479..5070b0230fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -6,7 +6,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send' import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, @@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: { // submit immediately so historical output cannot satisfy the match. observer.arm() } - const accepted = await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + const accepted = + options?.delivery === 'type' + ? await typeNativeChatCommand( + target.settings, + target.ptyId, + command, + sendController.signal + ) + : await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 3e2f0f57921..4e9396632c7 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, removeAgentArgs: removeCodexEffortOverride, - midSession: { kind: 'agent-picker', command: '/model' } + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } } @@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), - // Codex accepts a model argument in its live /model command. - midSession: { - kind: 'command', - build: (value) => `/model ${String(value)}`, - pickerCommand: '/model' - } + // Codex classifies multi-character writes as pasted prose; type the bare + // command and let its own picker apply the account-supported model. + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index bb08339e07e..ae2af3b450f 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -6,6 +6,7 @@ import type { } from './native-chat-session-options' export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' +export type CatalogCommandDelivery = 'type' export type CatalogMidSessionApply = | { @@ -15,7 +16,7 @@ export type CatalogMidSessionApply = detectAgentInteraction?: CatalogAgentInteractionDetection } | { kind: 'toggle-command'; command: string } - | { kind: 'agent-picker'; command: string } + | { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery } | { kind: 'unsupported' } export type CatalogOptionApply = { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index e5e254c45de..b242b6b27c9 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options' export type { AgentSessionOptionCatalog, CatalogAgentInteractionDetection, + CatalogCommandDelivery, CatalogMidSessionApply, CatalogModel, CatalogOption, diff --git a/src/shared/agent-tui-command-typing.ts b/src/shared/agent-tui-command-typing.ts new file mode 100644 index 00000000000..389d7e03141 --- /dev/null +++ b/src/shared/agent-tui-command-typing.ts @@ -0,0 +1,43 @@ +import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear' + +export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown' + +export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16 + +function waitForNextKey(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS) + const finish = (completed: boolean): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */ +export async function typeAgentTuiCommand(args: { + command: string + write: (key: string) => Promise + signal?: AbortSignal +}): Promise { + const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r'] + for (let index = 0; index < keys.length; index += 1) { + if (args.signal?.aborted) { + return 'rejected' + } + const outcome = await args.write(keys[index]!) + if (outcome !== 'accepted') { + return outcome + } + if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) { + return 'rejected' + } + } + return 'accepted' +} diff --git a/src/shared/native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts index 2583192d34b..77205f6ba5c 100644 --- a/src/shared/native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => { ).toBe('/fast') }) - it('builds an absolute command for live Codex model changes', () => { + it('does not turn a Codex model pick into pasted slash-command prose', () => { expect( buildNativeChatSessionOptionCommand({ optionId: 'model', @@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => { models: CODEX_SESSION_OPTION_CATALOG.models, record: createNativeChatSessionOptionRecord('codex') }) - ).toBe('/model gpt-5.5') + ).toBeNull() + expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({ + kind: 'agent-picker', + command: '/model', + delivery: 'type' + }) }) }) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index c6436f9aad4..961701b7415 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { }) }) - it('exposes Codex model changes as native selectable values', () => { + it('routes Codex model changes through its typed TUI picker', () => { const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: CODEX_SESSION_OPTION_CATALOG, models: CODEX_SESSION_OPTION_CATALOG.models, @@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { modelLabel: 'Model' }) expect(snapshot[0]).toMatchObject({ settable: true }) - expect(snapshot[0]?.action).toBeUndefined() + expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' }) expect(snapshot[0]?.kind).toMatchObject({ type: 'select', choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }]) From fb39308dfe005c92622cf56e7f2caab6e11892e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:24:56 -0700 Subject: [PATCH 007/114] refactor(daemon): remove abandoned binary framing (#13530) --- src/main/daemon/binary-frame.test.ts | 167 --------------------------- src/main/daemon/binary-frame.ts | 56 --------- src/main/daemon/types.ts | 17 --- 3 files changed, 240 deletions(-) delete mode 100644 src/main/daemon/binary-frame.test.ts delete mode 100644 src/main/daemon/binary-frame.ts diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. From a45adcfff0ac99a1e5aa8b3eb3711128313afe4e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:00 -0700 Subject: [PATCH 008/114] refactor(emulator): remove unwired scrcpy control encoders (#13528) --- .../android/scrcpy-control-protocol.test.ts | 205 ------------------ .../android/scrcpy-control-protocol.ts | 91 -------- .../emulator/android/scrcpy-stream-session.ts | 9 +- 3 files changed, 1 insertion(+), 304 deletions(-) delete mode 100644 src/main/emulator/android/scrcpy-control-protocol.test.ts delete mode 100644 src/main/emulator/android/scrcpy-control-protocol.ts diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return From a276e54e624bfbe418b7491c0221ed005460db8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:03 -0700 Subject: [PATCH 009/114] refactor(relay): remove unwired PTY replay index (#13518) --- src/relay/pty-source-replay-index.test.ts | 96 ----------- src/relay/pty-source-replay-index.ts | 201 ---------------------- 2 files changed, 297 deletions(-) delete mode 100644 src/relay/pty-source-replay-index.test.ts delete mode 100644 src/relay/pty-source-replay-index.ts diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} From d5c5322442c391146908ad5de41bd3473086077b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:06 -0700 Subject: [PATCH 010/114] refactor(computer): remove orphaned helpers (#13511) --- src/main/computer/key-mapping.test.ts | 59 ------------ src/main/computer/key-mapping.ts | 128 -------------------------- src/main/computer/permissions.test.ts | 120 ------------------------ src/main/computer/permissions.ts | 58 ------------ 4 files changed, 365 deletions(-) delete mode 100644 src/main/computer/key-mapping.test.ts delete mode 100644 src/main/computer/key-mapping.ts delete mode 100644 src/main/computer/permissions.test.ts delete mode 100644 src/main/computer/permissions.ts diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} From 837ea3feecd2bf3fa4bd7151a7ddaf8153adf6e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:09 -0700 Subject: [PATCH 011/114] refactor(onboarding): remove retired capability checklist (#13509) --- config/scripts/locale-ko-key-overrides.json | 9 - .../onboarding/AgentFeatureSetupStep.test.tsx | 30 ---- .../onboarding/AgentFeatureSetupStep.tsx | 64 ------- .../onboarding/FeatureSetupChecklist.tsx | 159 ------------------ src/renderer/src/i18n/locales/en.json | 14 -- src/renderer/src/i18n/locales/es.json | 14 -- src/renderer/src/i18n/locales/ja.json | 14 -- src/renderer/src/i18n/locales/ko.json | 14 -- src/renderer/src/i18n/locales/zh.json | 14 -- 9 files changed, 332 deletions(-) delete mode 100644 src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx delete mode 100644 src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx delete mode 100644 src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..7c0bf011de4 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1520,9 +1520,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1535,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx deleted file mode 100644 index 832af22219c..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { AgentFeatureSetupStep } from './AgentFeatureSetupStep' - -describe('AgentFeatureSetupStep', () => { - it('renders the agent feature setup checklist', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Agent Browser Use') - expect(html).toContain('Computer Use') - expect(html).toContain('Agent Orchestration') - expect(html).toContain('Linear agent skill') - expect(html).toContain('Enable capabilities') - expect(html).toContain('role="checkbox"') - }) -}) diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx deleted file mode 100644 index 647600e02bb..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Loader2, Terminal } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { FeatureSetupChecklist } from './FeatureSetupChecklist' -import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal' -import { - hasSelectedOnboardingFeatureSetup, - type OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type AgentFeatureSetupStepProps = { - featureSetup: OnboardingFeatureSetupSelection - onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void - featureSetupCommand: string | null - featureSetupCommandSelection: OnboardingFeatureSetupSelection | null - setupBusyLabel: string | null - onStartFeatureSetup: () => void -} - -export function AgentFeatureSetupStep({ - featureSetup, - onFeatureSetupChange, - featureSetupCommand, - featureSetupCommandSelection, - setupBusyLabel, - onStartFeatureSetup -}: AgentFeatureSetupStepProps): React.JSX.Element { - const hasSelectedFeatures = hasSelectedOnboardingFeatureSetup(featureSetup) - const showSetupAction = !featureSetupCommand - - return ( - <> - - {showSetupAction ? ( -
- -
- ) : null} - {featureSetupCommand ? ( - - ) : null} - - ) -} diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx deleted file mode 100644 index 36fb6443ce6..00000000000 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { ReactNode } from 'react' -import { Check, Globe2, MonitorCog, TicketCheck, Workflow } from 'lucide-react' -import { cn } from '@/lib/utils' -import type { - OnboardingFeatureSetupId, - OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type FeatureSetupChecklistProps = { - value: OnboardingFeatureSetupSelection - onChange: (value: OnboardingFeatureSetupSelection) => void -} - -type FeatureSetupRow = { - id: OnboardingFeatureSetupId - title: string - description: string - setupSummary: string - icon: ReactNode -} - -const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [ - { - id: 'browserUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628', - 'Agent Browser Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.01426f3a23', - 'Agents can navigate sites, inspect pages, and work through browser tasks.' - ) - }, - setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.', - icon: - }, - { - id: 'computerUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', - 'Computer Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.c5292c409d', - 'Agents can inspect app windows and operate local apps when you ask.' - ) - }, - setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.', - icon: - }, - { - id: 'orchestration', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.399cf885c0', - 'Agent Orchestration' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.77f74946f5', - 'Agents can message each other, take tasks, and coordinate handoffs.' - ) - }, - setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.', - icon: - }, - { - id: 'linearTickets', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsTitle', - 'Linear agent skill' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsDescription', - 'Agents can use linked Linear tasks for richer ticket-aware handoffs.' - ) - }, - get setupSummary() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsSetupSummary', - 'Recommended for Linear workspaces; does not affect Linear connection setup.' - ) - }, - icon: - } -] - -export function FeatureSetupChecklist({ - value, - onChange -}: FeatureSetupChecklistProps): React.JSX.Element { - return ( -
-
- {FEATURE_SETUP_ROWS.map((row) => { - const selected = value[row.id] - return ( - - ) - })} -
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 41486e6220f..10c9c94fd6f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12005,17 +12005,6 @@ "yoloPermissionsInfo": "Agent permission info", "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents can message each other, take tasks, and coordinate handoffs.", - "399cf885c0": "Agent Orchestration", - "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", - "1ecfb490ac": "Computer Use", - "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", "47fc6cc6dc": "Skill setup command", @@ -12165,9 +12154,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Enable capabilities" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Uses PowerShell 7+ when available, with Windows PowerShell as fallback.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9a80ab4e5f5..e572aebea50 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Información de permisos del agente", "yoloPermissionsTooltip": "Omite las comprobaciones de permisos de los agentes para reducir interrupciones" }, - "FeatureSetupChecklist": { - "77f74946f5": "Los agentes pueden enviarse mensajes entre sí, tomar tareas y coordinar handoffs.", - "399cf885c0": "Orquestación de agentes", - "c5292c409d": "Los agentes pueden inspeccionar ventanas de apps y operar apps locales cuando se lo pidas.", - "1ecfb490ac": "Uso de la computadora", - "01426f3a23": "Los agentes pueden navegar por sitios, inspeccionar páginas y trabajar en tareas del navegador.", - "ea85d9e628": "Uso del navegador por agentes", - "linearTicketsTitle": "Skill de agente de Linear", - "linearTicketsDescription": "Los agentes pueden usar tareas de Linear enlazadas para handoffs con más contexto de ticket.", - "linearTicketsSetupSummary": "Recomendado para espacios de trabajo de Linear; no afecta la configuración de conexión de Linear." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Pulsa Enter para ejecutar el comando y confirma npx si se te solicita. También puedes configurarlo más tarde en Configuración.", "47fc6cc6dc": "Comando de configuración de skills", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Activar capacidades" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Usa PowerShell 7+ cuando está disponible, con Windows PowerShell como alternativa.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index dd37babf73d..94acf2379a5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Agent 権限情報", "yoloPermissionsTooltip": "Agent の権限チェックをスキップして割り込みを減らす" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agent は相互にメッセージを送信し、タスクを実行し、引き継ぎを調整できます。", - "399cf885c0": "Agent オーケストレーション", - "c5292c409d": "Agent は、要求に応じてアプリウィンドウを検査し、ローカルアプリを操作できます。", - "1ecfb490ac": "コンピュータ操作", - "01426f3a23": "Agent はサイトを移動し、ページを検査し、ブラウザのタスクを実行できます。", - "ea85d9e628": "Agent ブラウザの使用", - "linearTicketsTitle": "Linear Agent スキル", - "linearTicketsDescription": "Agent はリンクされた Linear タスクを使って、チケットを踏まえたより詳しい引き継ぎができます。", - "linearTicketsSetupSummary": "Linear ワークスペースに推奨。Linear 接続設定には影響しません。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter キーを押してコマンドを実行し、要求されたら npx を確認します。これは、後で [設定] で設定することもできます。", "47fc6cc6dc": "スキルセットアップコマンド", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "CLI とスキルをインストール" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "利用可能な場合は PowerShell 7+ を使い、代わりに Windows PowerShell を使用します。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b0b476820c5..a3ee8dd668d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11493,17 +11493,6 @@ "yoloPermissionsInfo": "Agent 권한 정보", "yoloPermissionsTooltip": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents는 서로 메시지를 보내고, 작업을 수행하고, 핸드오프를 조정할 수 있습니다.", - "399cf885c0": "Agent 오케스트레이션", - "c5292c409d": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다.", - "1ecfb490ac": "컴퓨터 사용", - "01426f3a23": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다.", - "ea85d9e628": "Agent 브라우저 사용", - "linearTicketsTitle": "Linear agent 스킬", - "linearTicketsDescription": "Agents는 연결된 Linear 작업을 사용해 티켓 맥락을 반영한 더 풍부한 인계를 할 수 있습니다.", - "linearTicketsSetupSummary": "Linear 워크스페이스에 권장됩니다. Linear 연결 설정에는 영향을 주지 않습니다." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter를 눌러 명령을 실행하고 요청 시 npx를 확인합니다. 나중에 설정에서 설정할 수도 있습니다.", "47fc6cc6dc": "스킬 설정 명령어", @@ -11653,9 +11642,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "기능 활성화" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "사용할 수 있으면 PowerShell 7+를 사용하고, 없으면 Windows PowerShell을 사용합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0498519afb9..30aba037f97 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11513,17 +11513,6 @@ "yoloPermissionsInfo": "智能体权限信息", "yoloPermissionsTooltip": "跳过智能体权限检查,减少打断" }, - "FeatureSetupChecklist": { - "77f74946f5": "智能体可互相通信、领取任务并协调交接。", - "399cf885c0": "智能体编排", - "c5292c409d": "按你的要求,智能体可查看应用窗口并操作本地应用。", - "1ecfb490ac": "计算机控制", - "01426f3a23": "智能体可浏览网站、检查页面并完成浏览器任务。", - "ea85d9e628": "智能体浏览器操控", - "linearTicketsTitle": "Linear 智能体技能", - "linearTicketsDescription": "智能体可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", - "linearTicketsSetupSummary": "推荐用于 Linear 工作区;不会影响 Linear 连接设置。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "按 Enter 运行命令并在询问时确认 npx。您也可以稍后在“设置”中进行设置。", "47fc6cc6dc": "技能设置命令", @@ -11673,9 +11662,6 @@ } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "安装 CLI 和技能" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "在可用时使用 PowerShell 7+,否则回退到 Windows PowerShell。", From 9359edc2b01c7c38ce59766bf911da79600d5f08 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:13 -0700 Subject: [PATCH 012/114] refactor(workspaces): remove replaced host combobox (#13507) --- config/scripts/locale-ko-key-overrides.json | 6 - .../ProjectHostSetupCombobox.test.tsx | 157 ------------------ .../ProjectHostSetupCombobox.tsx | 105 ------------ src/renderer/src/i18n/locales/en.json | 4 - src/renderer/src/i18n/locales/es.json | 4 - src/renderer/src/i18n/locales/ja.json | 4 - src/renderer/src/i18n/locales/ko.json | 4 - src/renderer/src/i18n/locales/zh.json | 4 - 8 files changed, 288 deletions(-) delete mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx delete mode 100644 src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 7c0bf011de4..d3c2375bc45 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1502,12 +1502,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx deleted file mode 100644 index 06b1267f80b..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption -} from '@/lib/project-host-setup-options' -import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' - -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/command', () => ({ - Command: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandItem: ({ - children, - disabled, - onSelect, - value - }: { - children: React.ReactNode - disabled?: boolean - onSelect?: (value: string) => void - value: string - }) => ( - - ) -})) - -let container: HTMLDivElement -let root: Root - -const readyOption: ProjectHostSetupOption = { - id: 'local-setup', - kind: 'ready', - projectId: 'project-1', - hostId: 'local', - repoId: 'local-repo', - label: 'Local Mac', - detail: 'Orca', - path: '/Users/alice/orca' -} - -const needsSetupOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:ssh:builder', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'ssh:builder', - label: 'Builder', - detail: 'Project location not set', - isAvailable: true, - attention: false -} - -const unavailableOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:runtime:old', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'runtime:old', - label: 'Old server', - detail: 'Update Orca on this host to set up projects', - isAvailable: false, - attention: false -} - -beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() -}) - -function renderCombobox({ - onValueChange = vi.fn() -}: { - onValueChange?: (setupId: string) => void -} = {}): void { - act(() => { - root.render( - - ) - }) -} - -describe('ProjectHostSetupCombobox', () => { - it('routes ready setup rows through onValueChange', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - act(() => { - container - .querySelector('[data-command-value="local-setup"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(onValueChange).toHaveBeenCalledWith('local-setup') - }) - - it('hides hosts that need setup from the run target list', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - expect( - container.querySelector('[data-command-value="needs-setup:ssh:builder"]') - ).toBeNull() - expect(container.textContent).not.toContain('Project location not set') - expect(onValueChange).not.toHaveBeenCalled() - }) - - it('hides unavailable setup rows from the run target list', () => { - const onValueChange = vi.fn() - - act(() => { - root.render( - - ) - }) - - const unavailableButton = container.querySelector( - '[data-command-value="needs-setup:runtime:old"]' - ) - expect(unavailableButton).toBeNull() - expect(container.textContent).not.toContain('Update Orca on this host') - - expect(onValueChange).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx deleted file mode 100644 index 22df2d5f192..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' -import { translate } from '@/i18n/i18n' - -type ProjectHostSetupComboboxProps = { - options: readonly ProjectHostSetupOption[] - value: string | null - onValueChange: (setupId: string) => void -} - -export default function ProjectHostSetupCombobox({ - options, - value, - onValueChange -}: ProjectHostSetupComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const readyOptions = options.filter((option) => option.kind === 'ready') - const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null - - const handleSelect = React.useCallback( - (setupId: string): void => { - const option = options.find((candidate) => candidate.id === setupId) - if (!option) { - return - } - if (!readyOptions.some((candidate) => candidate.id === setupId)) { - return - } - onValueChange(setupId) - setOpen(false) - }, - [onValueChange, options, readyOptions] - ) - - return ( - - - - - - - - - {translate( - 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', - 'No hosts are ready for this project.' - )} - - {readyOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} -
-
-
- ))} -
-
-
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 10c9c94fd6f..f782865d3be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12254,10 +12254,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" - }, "ProjectCombobox": { "empty": "No projects match your search.", "addProject": "Add a new project", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e572aebea50..4447e45e0f7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hay hosts listos para este proyecto.", - "placeholder": "Elegir host" - }, "ProjectCombobox": { "empty": "Ningún proyecto coincide con tu búsqueda.", "addProject": "Add a new project", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 94acf2379a5..883ec789b60 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "この Jira Issue をリンクできませんでした。該当するサイトを選択するか Jira に再接続してから、もう一度お試しください。", "emoji": "絵文字" }, - "ProjectHostSetupCombobox": { - "empty": "このプロジェクトで準備が整ったホストはありません。", - "placeholder": "ホストを選択" - }, "ProjectCombobox": { "empty": "検索条件に一致するプロジェクトはありません。", "addProject": "新規プロジェクトを追加", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a3ee8dd668d..4641da71258 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11742,10 +11742,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "이 프로젝트에 준비된 호스트가 없습니다.", - "placeholder": "호스트 선택" - }, "ProjectCombobox": { "empty": "검색과 일치하는 프로젝트가 없습니다.", "addProject": "새 프로젝트 추가", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 30aba037f97..4f1f86c0a46 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11762,10 +11762,6 @@ "jiraSelectBindFailed": "无法关联此 Jira 议题。请选择匹配的站点或重新连接 Jira,然后重试。", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "此项目没有已就绪的主机。", - "placeholder": "选择主机" - }, "ProjectCombobox": { "empty": "没有匹配搜索的项目。", "addProject": "添加新项目", From 1979f5a3e36d6bd1ed08b6d8cbd39d2a5f9aa2e3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:16 -0700 Subject: [PATCH 013/114] refactor(linear): remove replaced team combobox (#13506) --- config/scripts/locale-ko-key-overrides.json | 3 - docs/STYLEGUIDE.md | 4 +- .../components/ui/team-multi-combobox.test.ts | 61 ----- .../src/components/ui/team-multi-combobox.tsx | 220 ------------------ src/renderer/src/i18n/locales/en.json | 9 - src/renderer/src/i18n/locales/es.json | 9 - src/renderer/src/i18n/locales/ja.json | 9 - src/renderer/src/i18n/locales/ko.json | 9 - src/renderer/src/i18n/locales/zh.json | 9 - 9 files changed, 2 insertions(+), 331 deletions(-) delete mode 100644 src/renderer/src/components/ui/team-multi-combobox.test.ts delete mode 100644 src/renderer/src/components/ui/team-multi-combobox.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index d3c2375bc45..5afce08514a 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -4961,9 +4961,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..c21496b40bc 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/src/renderer/src/components/ui/team-multi-combobox.test.ts b/src/renderer/src/components/ui/team-multi-combobox.test.ts deleted file mode 100644 index 9f231371c29..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../../shared/types' -import { - TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES, - filterTeamMultiComboboxTeams, - isTeamMultiComboboxQueryTooLarge -} from './team-multi-combobox' - -function team(overrides: Partial): LinearTeam { - return { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspaceId: 'workspace-1', - workspaceName: 'Workspace', - ...overrides - } -} - -describe('filterTeamMultiComboboxTeams', () => { - it('returns all teams for empty queries', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, '')).toBe(teams) - expect(filterTeamMultiComboboxTeams(teams, ' ')).toBe(teams) - }) - - it('matches team names and keys case-insensitively', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, 'ops')).toEqual([teams[1]]) - expect(filterTeamMultiComboboxTeams(teams, 'ENGINEER')).toEqual([teams[0]]) - }) - - it('rejects oversized pasted queries before reading teams', () => { - const oversizedQuery = 'secret-team-combobox'.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES) - const teams = [ - { - id: 'secret', - workspaceId: 'workspace-1', - get name(): string { - throw new Error('oversized team combobox queries must not scan names') - }, - get key(): string { - throw new Error('oversized team combobox queries must not scan keys') - } - } - ] as LinearTeam[] - - expect(isTeamMultiComboboxQueryTooLarge(oversizedQuery)).toBe(true) - expect(filterTeamMultiComboboxTeams(teams, oversizedQuery)).toEqual([]) - }) - - it('rejects oversized whitespace before trimming', () => { - const teams = [team({ id: 'eng' })] - - expect( - filterTeamMultiComboboxTeams(teams, ' '.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES + 1)) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/ui/team-multi-combobox.tsx b/src/renderer/src/components/ui/team-multi-combobox.tsx deleted file mode 100644 index e1795c4801a..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { LinearTeam } from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' - -type TeamMultiComboboxProps = { - teams: LinearTeam[] - selected: ReadonlySet - onChange: (next: ReadonlySet) => void - onSelectAll: () => void - triggerClassName?: string -} - -export const TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES = 2 * 1024 - -export function isTeamMultiComboboxQueryTooLarge( - query: string, - maxBytes = TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterTeamMultiComboboxTeams( - teams: readonly LinearTeam[], - query: string -): readonly LinearTeam[] { - if (isTeamMultiComboboxQueryTooLarge(query)) { - return [] - } - const trimmedQuery = query.trim() - if (!trimmedQuery) { - return teams - } - const lower = trimmedQuery.toLowerCase() - return teams.filter( - (team) => team.name.toLowerCase().includes(lower) || team.key.toLowerCase().includes(lower) - ) -} - -function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet): React.JSX.Element { - if (teams.length === 0) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - if (selected.size === teams.length) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - const selectedTeams = teams.filter((t) => selected.has(t.id)) - const [first, second, ...rest] = selectedTeams - return ( - - {first ? {first.key} : null} - {second ? , {second.key} : null} - {rest.length > 0 ? +{rest.length} : null} - - ) -} - -export default function TeamMultiCombobox({ - teams, - selected, - onChange, - onSelectAll, - triggerClassName -}: TeamMultiComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - - const filteredTeams = useMemo(() => filterTeamMultiComboboxTeams(teams, query), [teams, query]) - - const allSelected = selected.size === teams.length && teams.length > 0 - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) { - setQuery('') - } - }, []) - - const toggle = useCallback( - (teamId: string) => { - const next = new Set(selected) - if (next.has(teamId)) { - if (next.size <= 1) { - return - } - next.delete(teamId) - } else { - next.add(teamId) - } - onChange(next) - }, - [onChange, selected] - ) - - const handleSelectAll = useCallback(() => { - if (allSelected) { - const first = teams[0] - if (!first) { - return - } - onChange(new Set([first.id])) - return - } - onSelectAll() - }, [allSelected, onChange, onSelectAll, teams]) - - return ( - - - - - - - -
- -
- - - {translate( - 'auto.components.ui.team.multi.combobox.de83523bf9', - 'No teams match your search.' - )} - - {filteredTeams.map((team) => { - const isSelected = selected.has(team.id) - const isLastSelected = isSelected && selected.size <= 1 - return ( - toggle(team.id)} - disabled={isLastSelected} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - -
- - {team.name} - - {team.key} - - -
-
- ) - })} -
-
-
-
- ) -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f782865d3be..1c6022f232d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2709,15 +2709,6 @@ }, "sheet": { "1189e9fe0a": "Close" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "No teams match your search.", - "301f2a796e": "All teams", - "18ec58881e": "Search teams..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4447e45e0f7..4ed9cf2624b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "Cerrar" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "Ningún equipo coincide con tu búsqueda.", - "301f2a796e": "Todos los equipos", - "18ec58881e": "Buscar equipos..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 883ec789b60..1441a29bc12 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "閉じる" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "検索に一致するチームはありません。", - "301f2a796e": "全チーム", - "18ec58881e": "チームを検索…" - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4641da71258..2bcf63d3433 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "닫기" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "검색어와 일치하는 팀이 없습니다.", - "301f2a796e": "모든 팀", - "18ec58881e": "팀 검색..." - } - } } }, "terminal": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4f1f86c0a46..76a8f99e696 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2616,15 +2616,6 @@ }, "sheet": { "1189e9fe0a": "关闭" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "没有团队符合您的搜索。", - "301f2a796e": "所有团队", - "18ec58881e": "搜索团队..." - } - } } }, "terminal": { From aa5e275026584d577ef97a9a50ac4917197018e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:20 -0700 Subject: [PATCH 014/114] refactor(sidebar): remove retired activity helpers (#13505) --- .../WorktreeActivityStatusIndicator.test.tsx | 41 ---- .../WorktreeActivityStatusIndicator.tsx | 21 -- .../WorktreeList.lineage-child-card.test.ts | 4 - .../visible-worktree-activity-inputs.test.ts | 37 +--- .../visible-worktree-activity-inputs.ts | 25 --- .../sidebar/worktree-section-activity.test.ts | 200 ------------------ .../sidebar/worktree-section-activity.ts | 116 ---------- 7 files changed, 1 insertion(+), 443 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx delete mode 100644 src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-section-activity.ts diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx deleted file mode 100644 index 633dce8a46e..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator' - -const mocks = vi.hoisted(() => ({ - status: 'inactive' as WorktreeStatus -})) - -vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: vi.fn(() => mocks.status) -})) - -function renderMarkup(status: WorktreeStatus): string { - mocks.status = status - return renderToStaticMarkup( - React.createElement(WorktreeActivityStatusIndicator, { worktreeId: 'wt-child' }) - ) -} - -describe('WorktreeActivityStatusIndicator', () => { - beforeEach(() => { - mocks.status = 'inactive' - }) - - it('renders the shared inactive status for slept worktrees', () => { - const markup = renderMarkup('inactive') - - expect(markup).toContain('Inactive') - expect(markup).toContain('bg-neutral-500/40') - expect(markup).not.toContain('bg-emerald-500') - }) - - it('renders the shared active status when the worktree is live', () => { - const markup = renderMarkup('active') - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - }) -}) diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx deleted file mode 100644 index be187336da8..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import { getWorktreeStatusLabel } from '@/lib/worktree-status' -import StatusIndicator from './StatusIndicator' -import { useWorktreeActivityStatus } from './use-worktree-activity-status' - -export function WorktreeActivityStatusIndicator({ - worktreeId, - className -}: { - worktreeId: string - className?: string -}): React.JSX.Element { - const status = useWorktreeActivityStatus(worktreeId) - - return ( - <> -